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
This commit is contained in:
tegwick 2026-09-14 09:54:09 +02:00
parent 80cae28a74
commit c074237aac
10 changed files with 401 additions and 3 deletions

View file

@ -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.<key>` 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 |

View file

@ -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": []
}

View file

@ -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

View file

@ -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.<key> 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
}

View file

@ -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{

View file

@ -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 {

View file

@ -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

View file

@ -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}}
}
}
```

View file

@ -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": []
}

View file

@ -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.