diff --git a/examples/markitect/ambiguous_resource_manifest.yaml b/examples/markitect/ambiguous_resource_manifest.yaml new file mode 100644 index 0000000..e8a3654 --- /dev/null +++ b/examples/markitect/ambiguous_resource_manifest.yaml @@ -0,0 +1,13 @@ +id: markitect-ambiguous-example +system: markitect-tool +caring_profile: caring-0.4.0-rc2 +resources: + - id: document:ambiguous-note + type: document + parent: knowledge-base:markitect-example + path: examples/policy/ambiguous-note.md +actions: + - read +metadata: + source: examples/markitect/ambiguous_resource_manifest.yaml + flex_auth_contract: resource-registration-v0 diff --git a/internal/markitect/importer.go b/internal/markitect/importer.go new file mode 100644 index 0000000..a66c070 --- /dev/null +++ b/internal/markitect/importer.go @@ -0,0 +1,207 @@ +package markitect + +import ( + "fmt" + "slices" + + "github.com/netkingdom/flex-auth/internal/registry" + "github.com/netkingdom/flex-auth/pkg/api" +) + +const ( + // SystemID is the protected-system id emitted by Markitect manifests. + SystemID = "markitect-tool" + + namespaceVersion = "markitect-resource-namespace-v1" + namespaceDoc = "docs/markitect-resource-namespace.md" +) + +// Diagnostic describes a Markitect manifest import finding. +type Diagnostic struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + Resource string `json:"resource,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +// ImportResult captures the normalized manifest and diagnostics. +type ImportResult struct { + Manifest api.ResourceManifest `json:"manifest"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` +} + +type classification struct { + scopeLevel api.ScopeLevel + planes []api.Plane + parentTypes []string +} + +var classifications = map[string]classification{ + "knowledge_base": { + scopeLevel: api.ScopeLevelWorkspace, + planes: []api.Plane{api.PlaneIntent, api.PlaneData}, + }, + "repository": { + scopeLevel: api.ScopeLevelProject, + planes: []api.Plane{api.PlaneBuild, api.PlaneData}, + parentTypes: []string{"knowledge_base"}, + }, + "document": { + scopeLevel: api.ScopeLevelResource, + planes: []api.Plane{api.PlaneData}, + parentTypes: []string{"repository", "knowledge_base"}, + }, + "section": { + scopeLevel: api.ScopeLevelSubresource, + planes: []api.Plane{api.PlaneData}, + parentTypes: []string{"document"}, + }, + "span": { + scopeLevel: api.ScopeLevelField, + planes: []api.Plane{api.PlaneData}, + parentTypes: []string{"section", "document"}, + }, + "context_package": { + scopeLevel: api.ScopeLevelDataset, + planes: []api.Plane{api.PlaneIntent, api.PlaneData, api.PlanePolicy}, + parentTypes: []string{"knowledge_base", "repository", "document"}, + }, + "workflow_artifact": { + scopeLevel: api.ScopeLevelProcess, + planes: []api.Plane{api.PlaneExecution, api.PlaneData, api.PlaneAudit}, + parentTypes: []string{"context_package", "document"}, + }, + "export": { + scopeLevel: api.ScopeLevelRecord, + planes: []api.Plane{api.PlaneData, api.PlaneAudit}, + parentTypes: []string{"workflow_artifact", "context_package", "document"}, + }, +} + +// ImportResourceManifest validates, enriches, and imports a Markitect manifest. +func ImportResourceManifest(store *registry.Store, manifest api.ResourceManifest) (ImportResult, error) { + if store == nil { + return ImportResult{}, fmt.Errorf("registry store is required") + } + + result := ImportResult{ + Manifest: EnrichResourceManifest(manifest), + Diagnostics: ValidateResourceManifest(manifest), + } + if hasError(result.Diagnostics) { + return result, fmt.Errorf("markitect resource manifest has import errors") + } + if err := store.ImportResourceManifest(result.Manifest); err != nil { + return result, err + } + return result, nil +} + +// ValidateResourceManifest returns Markitect-specific import diagnostics. +func ValidateResourceManifest(manifest api.ResourceManifest) []Diagnostic { + var diagnostics []Diagnostic + if manifest.ID == "" { + diagnostics = append(diagnostics, errorDiagnostic("MARKITECT-MANIFEST-ID", "", "manifest id is required", "id")) + } + if manifest.System != SystemID { + diagnostics = append(diagnostics, errorDiagnostic("MARKITECT-SYSTEM", "", fmt.Sprintf("manifest system must be %q", SystemID), "system")) + } + if manifest.Metadata["flex_auth_contract"] != api.FlexAuthContractV0 { + diagnostics = append(diagnostics, errorDiagnostic("MARKITECT-CONTRACT", "", "metadata.flex_auth_contract must declare resource-registration-v0", "metadata.flex_auth_contract")) + } + if manifest.CaringProfile == "" { + diagnostics = append(diagnostics, warningDiagnostic("MARKITECT-CARING-PROFILE", "", "missing caring_profile; importer will default to caring-0.4.0-rc2", "caring_profile")) + } else if manifest.CaringProfile != api.CaringProfileCaring040RC2 { + diagnostics = append(diagnostics, errorDiagnostic("MARKITECT-CARING-PROFILE", "", fmt.Sprintf("unsupported caring_profile %q", manifest.CaringProfile), "caring_profile")) + } + + resourceTypes := make(map[string]string, len(manifest.Resources)) + for _, resource := range manifest.Resources { + resourceTypes[resource.ID] = resource.Type + } + for _, resource := range manifest.Resources { + diagnostics = append(diagnostics, validateResource(resource, resourceTypes)...) + } + return diagnostics +} + +// EnrichResourceManifest adds namespace and CARING classification metadata. +func EnrichResourceManifest(manifest api.ResourceManifest) api.ResourceManifest { + out := manifest + if out.CaringProfile == "" { + out.CaringProfile = api.CaringProfileCaring040RC2 + } + out.Metadata = copyMap(out.Metadata) + out.Metadata["markitect_namespace"] = namespaceVersion + out.Metadata["markitect_namespace_doc"] = namespaceDoc + + out.Resources = append([]api.Resource(nil), manifest.Resources...) + for i, resource := range out.Resources { + if class, ok := classifications[resource.Type]; ok { + resource.Attributes = copyMap(resource.Attributes) + resource.Attributes["markitect_resource_type"] = resource.Type + resource.Attributes["caring_scope_level"] = class.scopeLevel + resource.Attributes["caring_planes"] = append([]api.Plane(nil), class.planes...) + out.Resources[i] = resource + } + } + return out +} + +func validateResource(resource api.Resource, resourceTypes map[string]string) []Diagnostic { + var diagnostics []Diagnostic + if resource.ID == "" { + diagnostics = append(diagnostics, errorDiagnostic("MARKITECT-RESOURCE-ID", "", "resource id is required", "resources[].id")) + } + if resource.Type == "" { + diagnostics = append(diagnostics, errorDiagnostic("MARKITECT-RESOURCE-TYPE", resource.ID, "resource type is required", "resources[].type")) + return diagnostics + } + + class, ok := classifications[resource.Type] + if !ok { + diagnostics = append(diagnostics, errorDiagnostic("MARKITECT-RESOURCE-TYPE", resource.ID, fmt.Sprintf("unknown Markitect resource type %q", resource.Type), "resources[].type")) + return diagnostics + } + if resource.Parent != "" { + if parentType, ok := resourceTypes[resource.Parent]; ok && !slices.Contains(class.parentTypes, parentType) { + diagnostics = append(diagnostics, errorDiagnostic("MARKITECT-PARENT-TYPE", resource.ID, fmt.Sprintf("resource type %q cannot be parented by %q", resource.Type, parentType), "resources[].parent")) + } + } + if resource.Parent == "" && len(class.parentTypes) > 0 { + diagnostics = append(diagnostics, warningDiagnostic("MARKITECT-PARENT-MISSING", resource.ID, fmt.Sprintf("resource type %q usually declares a parent", resource.Type), "resources[].parent")) + } + if len(resource.Labels) == 0 { + diagnostics = append(diagnostics, warningDiagnostic("MARKITECT-LABELS-MISSING", resource.ID, "resource has no labels; CARING exposure classification may be ambiguous", "resources[].labels")) + } + if resource.TrustZone == "" { + diagnostics = append(diagnostics, warningDiagnostic("MARKITECT-TRUST-ZONE-MISSING", resource.ID, "resource has no trust_zone; CARING exposure classification may be ambiguous", "resources[].trust_zone")) + } + return diagnostics +} + +func errorDiagnostic(code, resource, message string, fields ...string) Diagnostic { + return Diagnostic{Code: code, Severity: "error", Resource: resource, Message: message, Fields: fields} +} + +func warningDiagnostic(code, resource, message string, fields ...string) Diagnostic { + return Diagnostic{Code: code, Severity: "warning", Resource: resource, Message: message, Fields: fields} +} + +func hasError(diagnostics []Diagnostic) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Severity == "error" { + return true + } + } + return false +} + +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/markitect/importer_test.go b/internal/markitect/importer_test.go new file mode 100644 index 0000000..938852f --- /dev/null +++ b/internal/markitect/importer_test.go @@ -0,0 +1,129 @@ +package markitect_test + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/netkingdom/flex-auth/internal/markitect" + "github.com/netkingdom/flex-auth/internal/registry" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestImportResourceManifestLoadsPinnedMarkitectShape(t *testing.T) { + var manifest api.ResourceManifest + loadYAML(t, filepath.Join("..", "..", "examples", "markitect", "resource_manifest.yaml"), &manifest) + + store := registry.NewStore() + result, err := markitect.ImportResourceManifest(store, manifest) + if err != nil { + t.Fatalf("ImportResourceManifest: %v", err) + } + + if result.Manifest.CaringProfile != api.CaringProfileCaring040RC2 { + t.Fatalf("CaringProfile = %q; want %q", result.Manifest.CaringProfile, api.CaringProfileCaring040RC2) + } + if !hasDiagnostic(result.Diagnostics, "MARKITECT-CARING-PROFILE", "warning") { + t.Fatalf("diagnostics = %+v; want missing CARING profile warning", result.Diagnostics) + } + + resource, ok := store.Resource(markitect.SystemID, "document:internal-note") + if !ok { + t.Fatal("document:internal-note was not imported") + } + if resource.Attributes["caring_scope_level"] != api.ScopeLevelResource { + t.Fatalf("caring_scope_level = %v; want Resource", resource.Attributes["caring_scope_level"]) + } +} + +func TestImportNamespaceResourceManifestClassifiesAllResources(t *testing.T) { + var manifest api.ResourceManifest + loadYAML(t, filepath.Join("..", "..", "examples", "markitect", "namespace_resource_manifest.yaml"), &manifest) + + store := registry.NewStore() + result, err := markitect.ImportResourceManifest(store, manifest) + if err != nil { + t.Fatalf("ImportResourceManifest: %v\n%+v", err, result.Diagnostics) + } + if hasSeverity(result.Diagnostics, "error") { + t.Fatalf("diagnostics = %+v; did not expect errors", result.Diagnostics) + } + + span, ok := store.Resource(markitect.SystemID, "span:internal-note#risk:customer-email") + if !ok { + t.Fatal("span resource was not imported") + } + if span.Attributes["caring_scope_level"] != api.ScopeLevelField { + t.Fatalf("span caring_scope_level = %v; want Field", span.Attributes["caring_scope_level"]) + } + if span.TrustZone != "restricted" { + t.Fatalf("span.TrustZone = %q; want restricted", span.TrustZone) + } +} + +func TestImportAmbiguousManifestReportsClassificationWarnings(t *testing.T) { + var manifest api.ResourceManifest + loadYAML(t, filepath.Join("..", "..", "examples", "markitect", "ambiguous_resource_manifest.yaml"), &manifest) + + store := registry.NewStore() + result, err := markitect.ImportResourceManifest(store, manifest) + if err != nil { + t.Fatalf("ImportResourceManifest: %v", err) + } + + if !hasDiagnostic(result.Diagnostics, "MARKITECT-LABELS-MISSING", "warning") { + t.Fatalf("diagnostics = %+v; want labels warning", result.Diagnostics) + } + if !hasDiagnostic(result.Diagnostics, "MARKITECT-TRUST-ZONE-MISSING", "warning") { + t.Fatalf("diagnostics = %+v; want trust zone warning", result.Diagnostics) + } +} + +func TestImportRejectsUnknownMarkitectResourceType(t *testing.T) { + manifest := api.ResourceManifest{ + ID: "bad", + System: markitect.SystemID, + CaringProfile: api.CaringProfileCaring040RC2, + Resources: []api.Resource{ + {ID: "unknown:1", Type: "unknown_type", Labels: []string{"internal"}, TrustZone: "internal"}, + }, + Metadata: map[string]any{"flex_auth_contract": api.FlexAuthContractV0}, + } + + _, err := markitect.ImportResourceManifest(registry.NewStore(), manifest) + if err == nil { + t.Fatal("ImportResourceManifest accepted unknown resource type") + } +} + +func hasDiagnostic(diagnostics []markitect.Diagnostic, code, severity string) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Code == code && diagnostic.Severity == severity { + return true + } + } + return false +} + +func hasSeverity(diagnostics []markitect.Diagnostic, severity string) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Severity == severity { + return true + } + } + return false +} + +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) + } +} diff --git a/workplans/FLEX-WP-0003-markitect-consumer-integration.md b/workplans/FLEX-WP-0003-markitect-consumer-integration.md index 31bea0a..2445263 100644 --- a/workplans/FLEX-WP-0003-markitect-consumer-integration.md +++ b/workplans/FLEX-WP-0003-markitect-consumer-integration.md @@ -62,7 +62,7 @@ each resource level to CARING scope and plane values. ```task id: FLEX-WP-0003-T002 -status: todo +status: done priority: high state_hub_task_id: "90082eaf-37f5-492f-a884-ff8eec0eccaa" ```