flex-auth/internal/policy/attribute_reads.go

207 lines
5.1 KiB
Go
Raw Normal View History

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
}