generated from coulomb/repo-seed
Implement deterministic check APIs
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
// Package decision implements check, batch_check, list_allowed, and
|
// Package decision implements check, batch_check, list_allowed, and explain on
|
||||||
// explain on top of the registry and policy packages.
|
// top of the registry and policy packages.
|
||||||
//
|
//
|
||||||
// Decision envelopes carry effect, reason, matched policy version,
|
// Decision envelopes carry effect, reason, matched policy version,
|
||||||
// matched rule, resource metadata, subject metadata, obligations,
|
// matched rule, resource metadata, subject metadata, obligations,
|
||||||
// diagnostics, and provenance. Envelopes are identical for local and
|
// diagnostics, and provenance. Envelopes are identical for local and
|
||||||
// delegated evaluation per ADR-003.
|
// delegated evaluation per ADR-003.
|
||||||
//
|
//
|
||||||
// Implementation lands in FLEX-WP-0002 P2.4 and P2.5.
|
// list_allowed and explain land in FLEX-WP-0002 P2.5.
|
||||||
package decision
|
package decision
|
||||||
|
|||||||
283
internal/decision/engine.go
Normal file
283
internal/decision/engine.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package decision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/netkingdom/flex-auth/internal/policy"
|
||||||
|
"github.com/netkingdom/flex-auth/internal/registry"
|
||||||
|
"github.com/netkingdom/flex-auth/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine evaluates deterministic standalone authorization checks against a
|
||||||
|
// local registry and one validated policy package.
|
||||||
|
type Engine struct {
|
||||||
|
store *registry.Store
|
||||||
|
policy *policy.Package
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine creates a standalone decision engine.
|
||||||
|
func NewEngine(store *registry.Store, policyPackage *policy.Package) (*Engine, error) {
|
||||||
|
if store == nil {
|
||||||
|
return nil, fmt.Errorf("registry store is required")
|
||||||
|
}
|
||||||
|
if policyPackage == nil {
|
||||||
|
return nil, fmt.Errorf("policy package is required")
|
||||||
|
}
|
||||||
|
if !policyPackage.Valid {
|
||||||
|
return nil, fmt.Errorf("policy package %q is not valid", policyPackage.Metadata.ID)
|
||||||
|
}
|
||||||
|
return &Engine{store: store, policy: policyPackage}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check evaluates one subject/action/resource request.
|
||||||
|
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||||
|
normalized, facts := e.normalizeRequest(request)
|
||||||
|
|
||||||
|
expectation, err := e.policy.Evaluate(ctx, normalized)
|
||||||
|
if err != nil {
|
||||||
|
return api.DecisionEnvelope{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.envelope(normalized, expectation, facts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchCheck evaluates one subject/action/context tuple against resources in
|
||||||
|
// request order.
|
||||||
|
func (e *Engine) 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 := e.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
|
||||||
|
}
|
||||||
|
|
||||||
|
type registryFacts struct {
|
||||||
|
subjectFound bool
|
||||||
|
resourceFound bool
|
||||||
|
subject api.Subject
|
||||||
|
resource api.Resource
|
||||||
|
matchedRelationship string
|
||||||
|
descriptor *api.CaringAccessDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) normalizeRequest(request api.CheckRequest) (api.CheckRequest, registryFacts) {
|
||||||
|
normalized := request
|
||||||
|
facts := registryFacts{}
|
||||||
|
|
||||||
|
if subject, ok := e.store.Subject(request.Subject.ID); ok {
|
||||||
|
facts.subjectFound = true
|
||||||
|
facts.subject = subject
|
||||||
|
normalized.Subject = enrichSubjectRef(request.Subject, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource, ok := e.store.Resource(request.Resource.System, request.Resource.ID); ok {
|
||||||
|
facts.resourceFound = true
|
||||||
|
facts.resource = resource
|
||||||
|
normalized.Resource = enrichResourceRef(request.Resource, resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.CaringContext != nil {
|
||||||
|
descriptor := *normalized.CaringContext
|
||||||
|
facts.descriptor = &descriptor
|
||||||
|
return normalized, facts
|
||||||
|
}
|
||||||
|
|
||||||
|
if descriptor, relationshipID := e.matchCaringDescriptor(normalized, facts); descriptor != nil {
|
||||||
|
facts.descriptor = descriptor
|
||||||
|
facts.matchedRelationship = relationshipID
|
||||||
|
normalized.CaringContext = descriptor
|
||||||
|
} else if facts.resourceFound && facts.resource.Caring != nil {
|
||||||
|
descriptor := *facts.resource.Caring
|
||||||
|
facts.descriptor = &descriptor
|
||||||
|
normalized.CaringContext = &descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized, facts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) matchCaringDescriptor(request api.CheckRequest, facts registryFacts) (*api.CaringAccessDescriptor, string) {
|
||||||
|
candidates := []string{request.Subject.ID}
|
||||||
|
if facts.subjectFound {
|
||||||
|
candidates = append(candidates, facts.subject.Groups...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, relationship := range e.store.RelationshipsForObject(request.Resource.ID) {
|
||||||
|
if relationship.Caring == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(candidates, relationship.Subject) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if relationship.System != "" && relationship.System != request.Resource.System {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if relationship.Tenant != "" && request.Resource.Tenant != "" && relationship.Tenant != request.Resource.Tenant {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
descriptor := *relationship.Caring
|
||||||
|
return &descriptor, relationship.ID
|
||||||
|
}
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichSubjectRef(ref api.SubjectRef, subject api.Subject) api.SubjectRef {
|
||||||
|
out := ref
|
||||||
|
if out.Type == "" {
|
||||||
|
out.Type = subject.Type
|
||||||
|
}
|
||||||
|
if out.Tenant == "" {
|
||||||
|
out.Tenant = subject.Tenant
|
||||||
|
}
|
||||||
|
out.Attributes = copyMap(out.Attributes)
|
||||||
|
addAttribute(out.Attributes, "display_name", subject.DisplayName)
|
||||||
|
addAttribute(out.Attributes, "organization_relation", subject.OrganizationRelation)
|
||||||
|
addAttribute(out.Attributes, "roles", subject.Roles)
|
||||||
|
addAttribute(out.Attributes, "groups", subject.Groups)
|
||||||
|
addAttributes(out.Attributes, subject.Claims)
|
||||||
|
addAttributes(out.Attributes, subject.Metadata)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichResourceRef(ref api.ResourceRef, resource api.Resource) api.ResourceRef {
|
||||||
|
out := ref
|
||||||
|
if out.Type == "" {
|
||||||
|
out.Type = resource.Type
|
||||||
|
}
|
||||||
|
out.Attributes = copyMap(out.Attributes)
|
||||||
|
addAttribute(out.Attributes, "path", resource.Path)
|
||||||
|
addAttribute(out.Attributes, "parent", resource.Parent)
|
||||||
|
addAttribute(out.Attributes, "labels", resource.Labels)
|
||||||
|
addAttribute(out.Attributes, "trust_zone", resource.TrustZone)
|
||||||
|
addAttribute(out.Attributes, "owner", resource.Owner)
|
||||||
|
addAttributes(out.Attributes, resource.Attributes)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpectation, facts registryFacts) api.DecisionEnvelope {
|
||||||
|
envelope := api.DecisionEnvelope{
|
||||||
|
RequestID: request.ID,
|
||||||
|
Effect: expectation.Effect,
|
||||||
|
Reason: expectation.Reason,
|
||||||
|
MatchedPolicyVersion: e.policy.Metadata.Version,
|
||||||
|
MatchedRule: expectation.Reason,
|
||||||
|
Resource: request.Resource,
|
||||||
|
Subject: request.Subject,
|
||||||
|
Obligations: expectation.Obligations,
|
||||||
|
Diagnostics: map[string]any{
|
||||||
|
"policy_package": e.policy.Metadata.ID,
|
||||||
|
"policy_status": e.policy.Metadata.Status,
|
||||||
|
"registry_subject": facts.subjectFound,
|
||||||
|
"registry_resource": facts.resourceFound,
|
||||||
|
"matched_relationship": facts.matchedRelationship,
|
||||||
|
},
|
||||||
|
Provenance: api.DecisionProvenance{
|
||||||
|
Evaluator: "flex-auth/local",
|
||||||
|
Mode: "standalone",
|
||||||
|
PolicyPackage: e.policy.Metadata.ID,
|
||||||
|
PolicyVersion: e.policy.Metadata.Version,
|
||||||
|
},
|
||||||
|
Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings),
|
||||||
|
}
|
||||||
|
envelope.ID = decisionID(e.policy.Metadata, request, envelope)
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) caringDecisionMetadata(descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata {
|
||||||
|
profile := e.policy.Metadata.Caring.Profile
|
||||||
|
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: "CARING-DESCRIPTOR-MISSING",
|
||||||
|
Severity: "warning",
|
||||||
|
Message: "no CARING descriptor matched the request",
|
||||||
|
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...)
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func decisionID(metadata api.PolicyPackageMetadata, request api.CheckRequest, envelope api.DecisionEnvelope) string {
|
||||||
|
data, _ := json.Marshal(struct {
|
||||||
|
PolicyID string `json:"policy_id"`
|
||||||
|
PolicyVersion string `json:"policy_version"`
|
||||||
|
Request api.CheckRequest `json:"request"`
|
||||||
|
Effect api.DecisionEffect `json:"effect"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}{
|
||||||
|
PolicyID: metadata.ID,
|
||||||
|
PolicyVersion: metadata.Version,
|
||||||
|
Request: request,
|
||||||
|
Effect: envelope.Effect,
|
||||||
|
Reason: envelope.Reason,
|
||||||
|
})
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return "decision:" + hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAttributes(target map[string]any, attrs map[string]any) {
|
||||||
|
for key, value := range attrs {
|
||||||
|
addAttribute(target, key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAttribute(target map[string]any, key string, value any) {
|
||||||
|
if isEmptyAttribute(value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := target[key]; !exists {
|
||||||
|
target[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyAttribute(value any) bool {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return typed == ""
|
||||||
|
case api.OrganizationRelation:
|
||||||
|
return typed == ""
|
||||||
|
case []api.CanonicalRole:
|
||||||
|
return len(typed) == 0
|
||||||
|
case []string:
|
||||||
|
return len(typed) == 0
|
||||||
|
default:
|
||||||
|
return value == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
156
internal/decision/engine_test.go
Normal file
156
internal/decision/engine_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package decision_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/netkingdom/flex-auth/internal/decision"
|
||||||
|
"github.com/netkingdom/flex-auth/internal/policy"
|
||||||
|
"github.com/netkingdom/flex-auth/internal/registry"
|
||||||
|
"github.com/netkingdom/flex-auth/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckUsesExplicitCaringContext(t *testing.T) {
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
var request api.CheckRequest
|
||||||
|
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &request)
|
||||||
|
|
||||||
|
got, err := engine.Check(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Check: %v", err)
|
||||||
|
}
|
||||||
|
again, err := engine.Check(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Check again: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.ID != again.ID {
|
||||||
|
t.Fatalf("decision id is not deterministic: %q != %q", got.ID, again.ID)
|
||||||
|
}
|
||||||
|
if got.Effect != api.DecisionEffectAllow {
|
||||||
|
t.Fatalf("got.Effect = %q; want allow", got.Effect)
|
||||||
|
}
|
||||||
|
if got.Reason != "reader_relation" {
|
||||||
|
t.Errorf("got.Reason = %q; want reader_relation", got.Reason)
|
||||||
|
}
|
||||||
|
if got.MatchedPolicyVersion != "v1" {
|
||||||
|
t.Errorf("got.MatchedPolicyVersion = %q; want v1", got.MatchedPolicyVersion)
|
||||||
|
}
|
||||||
|
if got.Subject.Type != api.SubjectTypeHuman || got.Subject.Attributes["groups"] == nil {
|
||||||
|
t.Errorf("got.Subject = %+v; want enriched human subject with groups", got.Subject)
|
||||||
|
}
|
||||||
|
if got.Resource.Type != "document" || got.Resource.Attributes["trust_zone"] != "internal" {
|
||||||
|
t.Errorf("got.Resource = %+v; want enriched document resource", got.Resource)
|
||||||
|
}
|
||||||
|
if got.Caring == nil || got.Caring.Descriptor == nil {
|
||||||
|
t.Fatal("got.Caring.Descriptor is nil")
|
||||||
|
}
|
||||||
|
if got.Caring.Descriptor.ID != "descriptor:tenant-alpha-document-reader" {
|
||||||
|
t.Errorf("got.Caring.Descriptor.ID = %q", got.Caring.Descriptor.ID)
|
||||||
|
}
|
||||||
|
if len(got.Caring.RestrictionsEvaluated) != 1 || got.Caring.RestrictionsEvaluated[0] != api.RestrictionExportBlocked {
|
||||||
|
t.Errorf("got.Caring.RestrictionsEvaluated = %v; want [ExportBlocked]", got.Caring.RestrictionsEvaluated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMatchesRegistryRelationshipDescriptor(t *testing.T) {
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
got, err := engine.Check(context.Background(), api.CheckRequest{
|
||||||
|
ID: "check:registry-descriptor",
|
||||||
|
Subject: api.SubjectRef{
|
||||||
|
ID: "user:alice",
|
||||||
|
},
|
||||||
|
Action: "read",
|
||||||
|
Resource: api.ResourceRef{
|
||||||
|
ID: "document:internal-note",
|
||||||
|
System: "markitect-tool",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Check: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Effect != api.DecisionEffectAllow {
|
||||||
|
t.Fatalf("got.Effect = %q; want allow", got.Effect)
|
||||||
|
}
|
||||||
|
if got.Caring == nil || got.Caring.Descriptor == nil {
|
||||||
|
t.Fatal("got.Caring.Descriptor is nil")
|
||||||
|
}
|
||||||
|
if got.Caring.Descriptor.SubjectType != api.SubjectTypeGroup {
|
||||||
|
t.Errorf("got.Caring.Descriptor.SubjectType = %q; want Group", got.Caring.Descriptor.SubjectType)
|
||||||
|
}
|
||||||
|
if got.Diagnostics["matched_relationship"] != "rel:alice-reader-internal-note" {
|
||||||
|
t.Errorf("matched_relationship = %v", got.Diagnostics["matched_relationship"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCheckPreservesResourceOrder(t *testing.T) {
|
||||||
|
engine := newTestEngine(t)
|
||||||
|
|
||||||
|
got, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{
|
||||||
|
ID: "batch:read-documents",
|
||||||
|
Subject: api.SubjectRef{
|
||||||
|
ID: "user:alice",
|
||||||
|
},
|
||||||
|
Action: "read",
|
||||||
|
Resources: []api.ResourceRef{
|
||||||
|
{ID: "document:internal-note", System: "markitect-tool"},
|
||||||
|
{ID: "document:missing", Type: "document", System: "markitect-tool"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BatchCheck: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(got) = %d; want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Resource.ID != "document:internal-note" || got[0].Effect != api.DecisionEffectAllow {
|
||||||
|
t.Fatalf("first decision = %+v; want allow for document:internal-note", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Resource.ID != "document:missing" || got[1].Effect != api.DecisionEffectDeny {
|
||||||
|
t.Fatalf("second decision = %+v; want deny for document:missing", got[1])
|
||||||
|
}
|
||||||
|
if got[0].ID == got[1].ID {
|
||||||
|
t.Fatalf("batch decisions have duplicate deterministic ids: %q", got[0].ID)
|
||||||
|
}
|
||||||
|
if got[1].Caring == nil || len(got[1].Caring.ConformanceFindings) == 0 {
|
||||||
|
t.Fatal("missing descriptor deny should carry a CARING conformance finding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestEngine(t *testing.T) *decision.Engine {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
store, err := registry.LoadFile(filepath.Join("..", "..", "examples", "caring", "registry_snapshot.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile registry: %v", err)
|
||||||
|
}
|
||||||
|
policyPackage, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "caring", "policy_package.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAndValidateFile policy: %v", err)
|
||||||
|
}
|
||||||
|
engine, err := decision.NewEngine(store, policyPackage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEngine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadYAML(t *testing.T, path string, out any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, out); err != nil {
|
||||||
|
t.Fatalf("unmarshal %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,11 @@ func LoadAndValidateFile(ctx context.Context, path string) (*Package, error) {
|
|||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evaluate runs the package decision entrypoint for a normalized check request.
|
||||||
|
func (p *Package) Evaluate(ctx context.Context, request api.CheckRequest) (api.DecisionExpectation, error) {
|
||||||
|
return p.evaluateDecision(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate runs metadata, CARING, OPA parse/test, and fixture validation.
|
// Validate runs metadata, CARING, OPA parse/test, and fixture validation.
|
||||||
func (p *Package) Validate(ctx context.Context) ValidationResult {
|
func (p *Package) Validate(ctx context.Context) ValidationResult {
|
||||||
result := ValidationResult{}
|
result := ValidationResult{}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ redact-with-obligation).
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: FLEX-WP-0002-T004
|
id: FLEX-WP-0002-T004
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f6427575-00af-4f3e-ab30-5b9a158343ef"
|
state_hub_task_id: "f6427575-00af-4f3e-ab30-5b9a158343ef"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user