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
|
||||
`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 |
|
||||
|
|
|
|||
|
|
@ -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.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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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"
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue