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:
parent
80cae28a74
commit
c074237aac
10 changed files with 401 additions and 3 deletions
|
|
@ -158,6 +158,21 @@ be inferred from the key name:
|
||||||
Package changes wait until T03 makes "read a declared registry key" a
|
Package changes wait until T03 makes "read a declared registry key" a
|
||||||
`flex-auth validate` finding rather than a review note.
|
`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
|
## Which digest a consumer can reproduce
|
||||||
|
|
||||||
| Field | Over | Consumer-computable |
|
| Field | Over | Consumer-computable |
|
||||||
|
|
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
|
||||||
24
examples/markitect/subject_manifest.yaml
Normal file
24
examples/markitect/subject_manifest.yaml
Normal 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
|
||||||
206
internal/policy/attribute_reads.go
Normal file
206
internal/policy/attribute_reads.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -165,6 +165,7 @@ func (p *Package) Validate(ctx context.Context) ValidationResult {
|
||||||
result := ValidationResult{}
|
result := ValidationResult{}
|
||||||
|
|
||||||
result.Diagnostics = append(result.Diagnostics, p.metadataDiagnostics()...)
|
result.Diagnostics = append(result.Diagnostics, p.metadataDiagnostics()...)
|
||||||
|
result.Diagnostics = append(result.Diagnostics, p.undeclaredAttributeDiagnostics()...)
|
||||||
result.CaringFindings = append(result.CaringFindings, p.caringFindings()...)
|
result.CaringFindings = append(result.CaringFindings, p.caringFindings()...)
|
||||||
if len(p.Fixtures) == 0 {
|
if len(p.Fixtures) == 0 {
|
||||||
result.Diagnostics = append(result.Diagnostics, Diagnostic{
|
result.Diagnostics = append(result.Diagnostics, Diagnostic{
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestFixtureMismatchInvalidatesPackage(t *testing.T) {
|
||||||
pkg, err := policy.Load([]byte(inlinePolicy(false, "deny")), "inline-policy.md")
|
pkg, err := policy.Load([]byte(inlinePolicy(false, "deny")), "inline-policy.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
14
internal/policy/testdata/undeclared-ceiling/policy_fixtures.yaml
vendored
Normal file
14
internal/policy/testdata/undeclared-ceiling/policy_fixtures.yaml
vendored
Normal 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
|
||||||
55
internal/policy/testdata/undeclared-ceiling/policy_package.md
vendored
Normal file
55
internal/policy/testdata/undeclared-ceiling/policy_package.md
vendored
Normal 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}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
26
internal/policy/testdata/undeclared-ceiling/registry_snapshot.json
vendored
Normal file
26
internal/policy/testdata/undeclared-ceiling/registry_snapshot.json
vendored
Normal 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": []
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "A policy cannot tell a registry fact from a caller assertion"
|
title: "A policy cannot tell a registry fact from a caller assertion"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: flex-auth
|
repo: flex-auth
|
||||||
status: active
|
status: finished
|
||||||
owner: claude
|
owner: claude
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
planning_priority: P1
|
planning_priority: P1
|
||||||
|
|
@ -116,7 +116,7 @@ subject. Secrets-engine, tenant-engine and qonto-assistant read no attributes.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: FLEX-WP-0025-T03
|
id: FLEX-WP-0025-T03
|
||||||
status: wait
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "8ee5baf9-b364-5d3e-9c49-d0c9eb014c10"
|
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
|
Gate: the check runs in `validate` and flags a package whose ceiling key is
|
||||||
undeclared, demonstrated against a deliberately broken fixture package.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue