From c074237aac5f19e9d086c5f50a9a06edc04d15f7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 14 Sep 2026 09:54:09 +0200 Subject: [PATCH] Make undeclared policy attribute reads a validate error. FLEX-WP-0025-T03: flex-auth validate flags input.*.attributes keys that no sibling registry or manifest supplies. A broken testdata package proves tests can pass while the ceiling remains caller-only. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267 --- docs/request-enrichment.md | 15 ++ examples/informed-decision-t03/registry.json | 18 +- examples/markitect/subject_manifest.yaml | 24 ++ internal/policy/attribute_reads.go | 206 ++++++++++++++++++ internal/policy/package.go | 1 + internal/policy/package_test.go | 34 +++ .../undeclared-ceiling/policy_fixtures.yaml | 14 ++ .../undeclared-ceiling/policy_package.md | 55 +++++ .../undeclared-ceiling/registry_snapshot.json | 26 +++ .../FLEX-WP-0025-fact-versus-assertion.md | 11 +- 10 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 examples/markitect/subject_manifest.yaml create mode 100644 internal/policy/attribute_reads.go create mode 100644 internal/policy/testdata/undeclared-ceiling/policy_fixtures.yaml create mode 100644 internal/policy/testdata/undeclared-ceiling/policy_package.md create mode 100644 internal/policy/testdata/undeclared-ceiling/registry_snapshot.json diff --git a/docs/request-enrichment.md b/docs/request-enrichment.md index 4673008..ec39f6b 100644 --- a/docs/request-enrichment.md +++ b/docs/request-enrichment.md @@ -158,6 +158,21 @@ be inferred from the key name: Package changes wait until T03 makes "read a declared registry key" a `flex-auth validate` finding rather than a review note. +## Validate flags undeclared attribute reads (FLEX-WP-0025-T03) + +`flex-auth validate --kind policy` now emits +`POLICY-ATTRIBUTE-UNDECLARED` (error) for every +`input.{resource,subject}.attributes.` the package reads that no sibling +`registry_snapshot.json`, `production_registry_snapshot.json`, +`resource_manifest.yaml`, or `subject_manifest.yaml` supplies. First-class +fields enrichment copies (`labels`, `roles`, `groups`, `claims.*`, +`metadata.*`, …) count as supplied. + +It cannot tell a ceiling from a lookup. The finding is the set a reviewer +must look at. Demonstrated against +`internal/policy/testdata/undeclared-ceiling/`: tests and fixtures pass, the +package does not, because `max_ttl_hours` is caller-only. + ## Which digest a consumer can reproduce | Field | Over | Consumer-computable | diff --git a/examples/informed-decision-t03/registry.json b/examples/informed-decision-t03/registry.json index 0a58cc8..f193573 100644 --- a/examples/informed-decision-t03/registry.json +++ b/examples/informed-decision-t03/registry.json @@ -1 +1,17 @@ -{"subjects": [], "resources": []} +{ + "subjects": [ + { + "id": "t03-reviewer", + "type": "Human", + "display_name": "T03 reviewer", + "organization_relation": "ServiceProvider", + "groups": ["net-kingdom-admins"], + "claims": { + "assurance": "aal2", + "principal_type_source": "authentication-derived", + "tenant_source": "registration-supplied" + } + } + ], + "resources": [] +} diff --git a/examples/markitect/subject_manifest.yaml b/examples/markitect/subject_manifest.yaml new file mode 100644 index 0000000..e519778 --- /dev/null +++ b/examples/markitect/subject_manifest.yaml @@ -0,0 +1,24 @@ +id: subjects:markitect-example +subjects: + - id: user:visitor + type: Human + display_name: Visitor + organization_relation: Customer + roles: [] + groups: [] + tenant: tenant:alpha + - id: user:steward + type: Human + display_name: Steward + organization_relation: ServiceProvider + roles: + - Operator + groups: + - group:platform-architecture + tenant: tenant:alpha +groups: + - id: group:platform-architecture + display_name: Platform Architecture + members: + - user:steward + tenant: tenant:alpha diff --git a/internal/policy/attribute_reads.go b/internal/policy/attribute_reads.go new file mode 100644 index 0000000..a860008 --- /dev/null +++ b/internal/policy/attribute_reads.go @@ -0,0 +1,206 @@ +package policy + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// POLICY-ATTRIBUTE-UNDECLARED is reported when a package reads +// input.{resource,subject}.attributes. that no sibling registry or +// manifest supplies. The key then resolves only from caller input +// (FLEX-WP-0025-T03 / FLEX-DEC-2026-012). +const undeclaredAttributeCode = "POLICY-ATTRIBUTE-UNDECLARED" + +var ( + attrDot = regexp.MustCompile(`input\.(resource|subject)\.attributes\.([A-Za-z_][A-Za-z0-9_]*)`) + attrGet = regexp.MustCompile(`object\.get\(\s*input\.(resource|subject)\.attributes\s*,\s*"([A-Za-z_][A-Za-z0-9_]*)"`) + attrNestedGet = regexp.MustCompile(`object\.get\(\s*object\.get\(\s*input\.(resource|subject)\s*,\s*"attributes"\s*,\s*\{\}\s*\)\s*,\s*"([A-Za-z_][A-Za-z0-9_]*)"`) +) + +type attributeRead struct { + Kind string + Key string +} + +func (p *Package) undeclaredAttributeDiagnostics() []Diagnostic { + reads := attributeReads(p.RegoModule) + if len(reads) == 0 { + return nil + } + dir := filepath.Dir(p.Source) + if dir == "." || dir == "" || !filepath.IsAbs(p.Source) && !fileExists(p.Source) { + // Inline documents have no sibling registry. Skip unless the source + // path points at a real file (LoadAndValidateFile). + if _, err := os.Stat(p.Source); err != nil { + return nil + } + } + supplied := siblingSuppliedAttributes(filepath.Dir(p.Source)) + var diagnostics []Diagnostic + seen := map[string]bool{} + for _, read := range reads { + id := read.Kind + "." + read.Key + if seen[id] { + continue + } + seen[id] = true + if supplied[read.Kind][read.Key] { + continue + } + diagnostics = append(diagnostics, Diagnostic{ + Code: undeclaredAttributeCode, + Severity: "error", + Message: fmt.Sprintf("package reads input.%s.attributes.%s, which no sibling registry or manifest supplies; the value can only come from the caller", read.Kind, read.Key), + Fields: []string{"input." + read.Kind + ".attributes." + read.Key}, + Metadata: map[string]any{ + "kind": read.Kind, + "key": read.Key, + }, + }) + } + sort.Slice(diagnostics, func(i, j int) bool { + return diagnostics[i].Message < diagnostics[j].Message + }) + return diagnostics +} + +func attributeReads(regoModule string) []attributeRead { + var reads []attributeRead + add := func(kind, key string) { + reads = append(reads, attributeRead{Kind: kind, Key: key}) + } + for _, re := range []*regexp.Regexp{attrDot, attrGet, attrNestedGet} { + for _, match := range re.FindAllStringSubmatch(regoModule, -1) { + add(match[1], match[2]) + } + } + return reads +} + +func siblingSuppliedAttributes(dir string) map[string]map[string]bool { + out := map[string]map[string]bool{ + "resource": {}, + "subject": {}, + } + names := []string{ + "registry_snapshot.json", + "production_registry_snapshot.json", + "registry.json", + "resource_manifest.yaml", + "subject_manifest.yaml", + } + for _, name := range names { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var doc any + if strings.HasSuffix(name, ".json") { + if err := json.Unmarshal(data, &doc); err != nil { + continue + } + } else if err := yaml.Unmarshal(data, &doc); err != nil { + continue + } + collectSuppliedAttributes(doc, out) + } + return out +} + +func collectSuppliedAttributes(doc any, out map[string]map[string]bool) { + obj, ok := asMap(doc) + if !ok { + return + } + if resources, ok := asList(obj["resources"]); ok { + for _, item := range resources { + collectResourceKeys(item, out["resource"]) + } + } + if manifests, ok := asList(obj["resource_manifests"]); ok { + for _, item := range manifests { + collectSuppliedAttributes(item, out) + } + } + if subjects, ok := asList(obj["subjects"]); ok { + for _, item := range subjects { + collectSubjectKeys(item, out["subject"]) + } + } +} + +func asList(value any) ([]any, bool) { + switch typed := value.(type) { + case []any: + return typed, true + default: + return nil, false + } +} + +func collectResourceKeys(item any, keys map[string]bool) { + obj, ok := asMap(item) + if !ok { + return + } + // First-class fields enrichment copies into attributes. + for _, key := range []string{"path", "parent", "labels", "trust_zone", "owner"} { + keys[key] = true + } + addMapKeys(obj["attributes"], keys) +} + +func collectSubjectKeys(item any, keys map[string]bool) { + obj, ok := asMap(item) + if !ok { + return + } + for _, key := range []string{"display_name", "organization_relation", "roles", "groups"} { + keys[key] = true + } + addMapKeys(obj["attributes"], keys) + addMapKeys(obj["claims"], keys) + addMapKeys(obj["metadata"], keys) +} + +func addMapKeys(value any, keys map[string]bool) { + obj, ok := asMap(value) + if !ok { + return + } + for key := range obj { + keys[key] = true + } +} + +func asMap(value any) (map[string]any, bool) { + switch typed := value.(type) { + case map[string]any: + return typed, true + case map[any]any: + return stringifyKeys(typed), true + default: + return nil, false + } +} + +func stringifyKeys(in map[any]any) map[string]any { + out := make(map[string]any, len(in)) + for key, value := range in { + out[fmt.Sprint(key)] = value + } + return out +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/policy/package.go b/internal/policy/package.go index 644d5aa..aea1be1 100644 --- a/internal/policy/package.go +++ b/internal/policy/package.go @@ -165,6 +165,7 @@ func (p *Package) Validate(ctx context.Context) ValidationResult { result := ValidationResult{} result.Diagnostics = append(result.Diagnostics, p.metadataDiagnostics()...) + result.Diagnostics = append(result.Diagnostics, p.undeclaredAttributeDiagnostics()...) result.CaringFindings = append(result.CaringFindings, p.caringFindings()...) if len(p.Fixtures) == 0 { result.Diagnostics = append(result.Diagnostics, Diagnostic{ diff --git a/internal/policy/package_test.go b/internal/policy/package_test.go index 7b4c718..796422e 100644 --- a/internal/policy/package_test.go +++ b/internal/policy/package_test.go @@ -122,6 +122,40 @@ func TestCaringFindingsAreAdvisoryUntilEnforced(t *testing.T) { } } +func TestValidateFlagsUndeclaredAttributeRead(t *testing.T) { + pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("testdata", "undeclared-ceiling", "policy_package.md")) + if err != nil { + t.Fatalf("LoadAndValidateFile: %v", err) + } + if pkg.Valid { + t.Fatalf("pkg.Valid = true; want undeclared ceiling to fail validate\n%s", formatValidation(pkg.Validation)) + } + found := false + for _, diagnostic := range pkg.Validation.Diagnostics { + if diagnostic.Code == "POLICY-ATTRIBUTE-UNDECLARED" && strings.Contains(diagnostic.Message, "max_ttl_hours") { + found = true + if diagnostic.Severity != "error" { + t.Fatalf("undeclared diagnostic severity = %q; want error", diagnostic.Severity) + } + } + } + if !found { + t.Fatalf("missing POLICY-ATTRIBUTE-UNDECLARED for max_ttl_hours\n%s", formatValidation(pkg.Validation)) + } +} + +func TestOpsWardenAttributeReadsAreDeclared(t *testing.T) { + pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "ops-warden", "policy_package.md")) + if err != nil { + t.Fatalf("LoadAndValidateFile: %v", err) + } + for _, diagnostic := range pkg.Validation.Diagnostics { + if diagnostic.Code == "POLICY-ATTRIBUTE-UNDECLARED" { + t.Fatalf("ops-warden undeclared attribute: %s", diagnostic.Message) + } + } +} + func TestFixtureMismatchInvalidatesPackage(t *testing.T) { pkg, err := policy.Load([]byte(inlinePolicy(false, "deny")), "inline-policy.md") if err != nil { diff --git a/internal/policy/testdata/undeclared-ceiling/policy_fixtures.yaml b/internal/policy/testdata/undeclared-ceiling/policy_fixtures.yaml new file mode 100644 index 0000000..6872f54 --- /dev/null +++ b/internal/policy/testdata/undeclared-ceiling/policy_fixtures.yaml @@ -0,0 +1,14 @@ +- id: fixture:undeclared-ceiling-allow + request: + subject: + id: user:alice + action: sign + resource: + id: secret:example + attributes: + max_ttl_hours: 8 + context: + ttl_hours: 1 + expect: + effect: allow + reason: ttl_ok diff --git a/internal/policy/testdata/undeclared-ceiling/policy_package.md b/internal/policy/testdata/undeclared-ceiling/policy_package.md new file mode 100644 index 0000000..97798d5 --- /dev/null +++ b/internal/policy/testdata/undeclared-ceiling/policy_package.md @@ -0,0 +1,55 @@ +--- +id: testdata.undeclared-ceiling +name: deliberately undeclared ceiling key +namespace: testdata:secret +version: v1 +status: fixture +package: flexauth.testdata.undeclared_ceiling +actions: + - sign +owner: team:platform-security +fixtures: + - policy_fixtures.yaml +caring: + profile: caring-0.4.0-rc2 + enforce: false + canonical_roles: [Operator] + organization_relations: [ServiceProvider] + scopes: + - {level: Platform, id: platform:testdata, tenant: tenant:platform} + planes: [Secret] + capabilities: [Use] + exposure_modes: [Metadata] + conditions: [Logged] + restrictions: [PrivilegeEscalationBlocked] +--- + +# Undeclared ceiling (FLEX-WP-0025-T03) + +This package exists to prove `flex-auth validate` flags a ceiling read from a +key the sibling registry never supplies. Do not copy it. + +```rego +import future.keywords.if + +default decision := {"effect": "deny", "reason": "no_matching_rule"} + +decision := {"effect": "allow", "reason": "ttl_ok"} if { + input.action == "sign" + input.context.ttl_hours <= input.resource.attributes.max_ttl_hours +} +``` + +```rego test +package flexauth.testdata.undeclared_ceiling_test +import future.keywords.if +import data.flexauth.testdata.undeclared_ceiling + +test_allow if { + undeclared_ceiling.decision.effect == "allow" with input as { + "action": "sign", + "context": {"ttl_hours": 1}, + "resource": {"attributes": {"max_ttl_hours": 8}} + } +} +``` diff --git a/internal/policy/testdata/undeclared-ceiling/registry_snapshot.json b/internal/policy/testdata/undeclared-ceiling/registry_snapshot.json new file mode 100644 index 0000000..c3449d6 --- /dev/null +++ b/internal/policy/testdata/undeclared-ceiling/registry_snapshot.json @@ -0,0 +1,26 @@ +{ + "systems": [ + { + "id": "testdata", + "name": "Undeclared ceiling fixture" + } + ], + "resource_manifests": [ + { + "id": "testdata-secrets", + "system": "testdata", + "resources": [ + { + "id": "secret:example", + "type": "secret", + "attributes": { + "actor_id": "example" + } + } + ] + } + ], + "subjects": [], + "groups": [], + "relationships": [] +} diff --git a/workplans/FLEX-WP-0025-fact-versus-assertion.md b/workplans/FLEX-WP-0025-fact-versus-assertion.md index d55496d..1946a0e 100644 --- a/workplans/FLEX-WP-0025-fact-versus-assertion.md +++ b/workplans/FLEX-WP-0025-fact-versus-assertion.md @@ -4,7 +4,7 @@ type: workplan title: "A policy cannot tell a registry fact from a caller assertion" domain: infotech repo: flex-auth -status: active +status: finished owner: claude topic_slug: netkingdom planning_priority: P1 @@ -116,7 +116,7 @@ subject. Secrets-engine, tenant-engine and qonto-assistant read no attributes. ```task id: FLEX-WP-0025-T03 -status: wait +status: done priority: medium state_hub_task_id: "8ee5baf9-b364-5d3e-9c49-d0c9eb014c10" ``` @@ -134,3 +134,10 @@ caller input, which is the set a reviewer must look at. Gate: the check runs in `validate` and flags a package whose ceiling key is undeclared, demonstrated against a deliberately broken fixture package. + +**Done 2026-09-14.** `flex-auth validate --kind policy` emits +`POLICY-ATTRIBUTE-UNDECLARED` when a sibling registry/manifest does not supply +the key. Testdata package `internal/policy/testdata/undeclared-ceiling` has +passing tests and fixtures and still fails validate. Published packages remain +valid. `examples/informed-decision-t03/registry.json` now declares the subject +claims that policy reads.