# Reference Rego policy that mirrors zddc-server's built-in `internal` # decider exactly. Federal customers running their own OPA can use this # as a starting point (and then tighten — e.g. flip the leaf-allow-overrides- # parent-deny rule for NIST AC-6 compliance). # # The internal evaluator (in zddc/internal/zddc/acl.go) is the source of # truth for production. This file is validated against that evaluator on # every CI run via the parity test in zddc/internal/policy/parity_test.go. # Both implementations must produce the same decision for every fixture. # # Input shape (matches zddc/internal/policy.AllowInput JSON encoding): # { # "user": {"email": "alice@example.com"}, # "path": "/Project-A/sub/", # "policy_chain": { # "levels": [ # {"acl": {}, "admins": ["admin@example.com"]}, # {"acl": {"allow": ["*@example.com"]}} # ], # "has_any_file": true # } # } # # Levels are ordered ROOT → LEAF (deepest level last). Cascade walks # bottom-up (deepest first); first explicit match wins; within a single # level, a deny pattern is checked before an allow pattern. # # Default-allow when has_any_file is false (no .zddc anywhere → public); # default-deny when has_any_file is true and nothing matched (the safety # net the file at /.zddc enables). package zddc.access import future.keywords.if import future.keywords.in default allow := false # Allow when no .zddc files anywhere in the chain AND no rule matches. allow if { not input.policy_chain.has_any_file count(matched_levels) == 0 } # Allow when the deepest matching level grants. allow if { count(matched_levels) > 0 deepest := max(matched_levels) level_grants(input.policy_chain.levels[deepest]) } # Set of level indices where the email matches at least one allow or deny # pattern. The deepest-index member is the level whose decision counts. matched_levels := {i | some i level_matches(input.policy_chain.levels[i]) } # A level "matches" if its email is in either its deny list or its allow # list. Whether the level grants or denies is a separate question # (level_grants below) — deny is checked before allow within a level. level_matches(level) if { some pattern in level.acl.deny email_matches(pattern, input.user.email) } level_matches(level) if { some pattern in level.acl.allow email_matches(pattern, input.user.email) } # A level grants iff (a) no deny pattern matches at this level AND (b) some # allow pattern matches. Mirrors AllowedAtLevel in acl.go: deny is checked # first; if no deny hit, an allow match returns true. level_grants(level) if { not level_denies(level) some pattern in level.acl.allow email_matches(pattern, input.user.email) } level_denies(level) if { some pattern in level.acl.deny email_matches(pattern, input.user.email) } # email_matches: glob-match a pattern against an email, with the @-boundary # rule from acl.go's MatchesPattern: * does not cross @. Four cases: # # 1. exact match (covers patterns with no wildcard) # 2. bare "*" matches any non-empty email (special case because OPA's # glob.match treats empty delimiters [] inconsistently for the # lone-* pattern) # 3. pattern has both * and @: standard glob with @ as a delimiter so # `*@example.com` matches alice@example.com but `*example.com` # does NOT match anything (* won't cross @) # 4. pattern has * but no @: glob against the full email with no # delimiter (so `alice*` matches alice@anything) email_matches(pattern, email) if { pattern == email } email_matches(pattern, email) if { pattern == "*" email != "" } email_matches(pattern, email) if { contains(pattern, "*") contains(pattern, "@") glob.match(pattern, ["@"], email) } email_matches(pattern, email) if { contains(pattern, "*") not contains(pattern, "@") pattern != "*" glob.match(pattern, [], email) }