The bundled reference Rego (`zddc-server --print-rego`) modeled the read-ACL cascade only, but its header claimed to "mirror the internal decider exactly, validated on every CI run." It is verb-blind, role-blind, WORM-blind, and admin-blind: an external-OPA deployment (ZDDC_OPA_URL=http(s)/unix) loading it granted writes/deletes to read-only principals and ignored WORM zones. The parity tests never exercised a write action, a role principal, a WORM level, or is_active_admin — so the divergence shipped silently behind a false "mirrors exactly" claim. Make both shipped policies fail-closed instead of falsely-complete: - access.rego / access_federal.rego: gate every cascade grant on a read action (empty/absent == read); non-read actions fall through to default-deny. access.rego honors the single is_active_admin bypass (the one write-capable principal); access_federal.rego deliberately has none (strict AC-6). - Rewrite the access.rego / access_federal.rego / rego.go headers: these are read-ACL SKELETONS, NOT a tested mirror of the internal decider; operators must add write/WORM/role/admin semantics before granting writes. - policy.go: fix the stale AllowInput doc claiming the internal decider "treats read and write identically — any allow grants full CRUD" (it honors the action verb, with the WORM clamp and admin/elevation bypass applied). Tests: - rego_failclosed_test.go: pins the contract — reads allowed, every write verb denied, active-admin writes allowed (commercial) / denied (federal). - embedded_neutral_test.go: pins that EmbeddedDefaults() carries no top-level worm: and no role members — the invariant that makes policy.SerializableChain dropping PolicyChain.Embedded behavior-neutral (a latent wire-contract gap). Existing read-cascade parity + federal-divergence tests stay green; full Go suite + vet pass. The default in-process InternalDecider is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
5.3 KiB
Rego
155 lines
5.3 KiB
Rego
# 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)
|
|
}
|