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
206 lines
5.1 KiB
Go
206 lines
5.1 KiB
Go
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
|
|
}
|