# Reference Rego SKELETON for an external-OPA deployment. It models the
# read-ACL cascade ONLY. It is NOT semantically equivalent to zddc-server's
# built-in `internal` decider and MUST NOT be deployed as-is for a system
# that relies on write authorization.
#
# Models: the deepest-matching-level read-ACL cascade — glob patterns,
# deny-first-within-a-level, default-deny once any .zddc exists.
#
# Does NOT model (the internal decider in zddc/internal/zddc + internal/policy
# does): per-verb authorization (write/create/delete/admin), WORM zones,
# roles: membership resolution, inherit:false fences, and standing config-edit
# (the `a` verb). Because those are unmodelled this policy is FAIL-CLOSED:
# every non-read action is denied, and an elevated admin
# (input.user.is_active_admin) is the only write-capable principal. A real
# deployment must add the missing semantics before granting writes — see the
# parity tests under zddc/internal/policy for the dimensions to cover. The
# internal Go decider remains the production source of truth; this file is a
# starting point, not a tested mirror of it.
#
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
#   {
#     "user": {"email": "alice@example.com", "is_active_admin": false},
#     "action": "read",   # "" / absent == read; else write|create|delete|admin
#     "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

# Elevated admins bypass — mirrors the internal decider's single admin
# short-circuit. The caller computes is_active_admin (admin authority on this
# chain AND elevated/opted-in); trusting it here is the same trust the
# internal decider applies. This is the ONLY path that authorizes a non-read
# action under this read-ACL skeleton.
allow if {
	input.user.is_active_admin
}

# This policy models read-ACL only, so every cascade grant below is gated on a
# read action; any write/create/delete/admin falls through to the default-deny
# above (fail-closed). Empty/absent action == read.
is_read_action if {
	not input.action
}

is_read_action if {
	input.action == ""
}

is_read_action if {
	input.action == "read"
}

# Read allowed when no .zddc files anywhere in the chain AND no rule matches.
allow if {
	is_read_action
	not input.policy_chain.has_any_file
	count(matched_levels) == 0
}

# Read allowed when the deepest matching level grants.
allow if {
	is_read_action
	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)
}
