# 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": {"permissions": {"*@example.com": "rwcd"}}}
#       ],
#       "has_any_file": true
#     }
#   }
#
# acl.permissions maps each principal pattern to a verb string drawn from
# {r,w,c,d,a}. An empty verb string is an explicit deny.
#
# Levels are ordered ROOT → LEAF (deepest level last). Cascade walks
# bottom-up (deepest first); first explicit match wins; within a single
# level, an explicit-deny entry is checked before a grant entry.
#
# 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_ROOT>/.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 permission
# entry. 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 some permission entry's pattern matches the email
# (regardless of whether the verb string grants or denies). Whether the
# level grants or denies is a separate question (level_grants below).
level_matches(level) if {
	some pattern, _ in level.acl.permissions
	email_matches(pattern, input.user.email)
}

# A level grants iff (a) no explicit-deny entry at this level matches AND
# (b) some grant entry (non-empty verbs) matches. Mirrors
# GrantedVerbsAtLevel in acl.go: explicit deny wins within a level.
level_grants(level) if {
	not level_denies(level)
	some pattern, verbs in level.acl.permissions
	verbs != ""
	email_matches(pattern, input.user.email)
}

level_denies(level) if {
	some pattern, verbs in level.acl.permissions
	verbs == ""
	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)
}
