feat(server): federal-mode reference Rego (parent-deny-is-absolute)
Ship a second parity-tested Rego policy that flips the cascade's
leaf-allow-overrides-parent-deny rule for NIST AC-6 conformance.
Standard cascade (existing access.rego, mirrors internal Go evaluator):
Bottom-up walk; first explicit match wins; deny-first within a level.
A leaf-level allow CAN override an ancestor's deny. This is the
cascade's intentional delegation property — a project-owner who
re-allows a previously-denied collaborator works as expected.
Federal mode (new access_federal.rego):
Any deny anywhere along the chain is absolute. An allow only matters
if no level (any depth) has denied the same email. Required by
NIST AC-6 default expectations: a central admin's deny at the root
must be unbypassable by a tenant who controls a subtree's .zddc.
Operators run real OPA with this Rego and point ZDDC_OPA_URL at it;
the internal Go evaluator stays on the commercial cascade. The
toggle is "which policy does your OPA evaluate," not a knob inside
zddc-server.
Surfaced via --print-rego flag:
zddc-server --print-rego # standard (default)
zddc-server --print-rego=standard # same
zddc-server --print-rego=federal # AC-6 strict variant
Parity test (federal_parity_test.go) compiles both Regos and asserts:
* They AGREE on every cascade scenario where no ancestor-deny
intersects a leaf-allow (most cases).
* They DISAGREE — by design — on the three scenarios where the
AC-6 rule differs:
- "leaf allows what parent denied" → standard allows, federal denies
- "deep leaf re-allows after middle deny" → same
- "glob deny at root + specific allow at leaf" → same
Cross-checks the divergence flag explicitly so any future change that
accidentally collapses the two policies fails the test.
Closes the AC-6 row of the federal-readiness gap analysis (now marked
"partially complete" in zddc/README.md — the full bullet would be a
built-in --policy-mode=federal toggle that also flips the in-process
Go evaluator).
Production binary unchanged at 13.1 MB (Rego files embedded as bytes;
OPA library remains test-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2607ca9b8a
commit
d3a9ea7ad9
5 changed files with 341 additions and 17 deletions
|
|
@ -585,10 +585,16 @@ have to redo the gap analysis from scratch.
|
||||||
allow/deny + a single root-admin role. Required: roles as first-class
|
allow/deny + a single root-admin role. Required: roles as first-class
|
||||||
entities, `.zddc` syntax for role grants, identity-source-driven role
|
entities, `.zddc` syntax for role grants, identity-source-driven role
|
||||||
assignment.
|
assignment.
|
||||||
- **Least-privilege bounding** (NIST AC-6) — leaf-allow-overrides-parent-deny
|
- **Least-privilege bounding** (NIST AC-6) — *partially complete.*
|
||||||
is incompatible with default federal expectations. Required: a configurable
|
Leaf-allow-overrides-parent-deny is the cascade's intentional
|
||||||
enforcement mode where parent denies are absolute and only root admins can
|
delegation behavior in commercial mode and is preserved in the
|
||||||
override.
|
internal Go evaluator. For federal deployments, `--print-rego=federal`
|
||||||
|
emits a parity-tested Rego policy where parent denies are absolute;
|
||||||
|
drop it into an external OPA and point `ZDDC_OPA_URL` at it. *Still
|
||||||
|
required for full coverage:* a built-in toggle (e.g. `ZDDC_POLICY_MODE=federal`)
|
||||||
|
that switches the in-process Go evaluator's semantics without
|
||||||
|
requiring an OPA sidecar — currently federal-mode is reachable only
|
||||||
|
via the external-OPA path.
|
||||||
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
||||||
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
||||||
documented integration with at least one IdP supporting federal identity
|
documented integration with at least one IdP supporting federal identity
|
||||||
|
|
@ -714,20 +720,36 @@ cache lookup would be.
|
||||||
|
|
||||||
### Reference Rego policy
|
### Reference Rego policy
|
||||||
|
|
||||||
The `--print-rego` flag emits the bundled reference Rego policy that
|
The `--print-rego` flag emits the bundled reference Rego policies. Two
|
||||||
mirrors internal-mode semantics exactly. Federal customers standing up
|
variants ship:
|
||||||
their own OPA instance can use it as a starting point:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
|
zddc-server --print-rego # standard cascade (commercial)
|
||||||
|
zddc-server --print-rego=standard # same
|
||||||
|
zddc-server --print-rego=federal # parent-deny-is-absolute (NIST AC-6)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The standard variant mirrors internal-mode semantics exactly — leaf-
|
||||||
|
level allows can override an ancestor's deny (the cascade's intentional
|
||||||
|
delegation property). The federal variant is the strict-least-privilege
|
||||||
|
posture: any deny anywhere in the chain is absolute, no leaf-level
|
||||||
|
override possible. Federal customers running their own OPA can drop
|
||||||
|
the federal Rego in unchanged, or use either as a starting point for
|
||||||
|
further customization.
|
||||||
|
|
||||||
Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
|
Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
|
||||||
imports the OPA Go module **as a test-only dependency**, evaluates the
|
imports the OPA Go module **as a test-only dependency**, evaluates both
|
||||||
bundled Rego against the same fixture set the internal Go evaluator
|
bundled Regos against fixture sets and asserts:
|
||||||
runs, and fails CI on any divergence. The test-only import means the
|
|
||||||
production binary stays OPA-free (still 13 MB) — the OPA library is in
|
- The standard Rego matches the internal Go evaluator on every documented
|
||||||
`go.mod` but not in `go build`'s output.
|
cascade scenario (`TestRegoParity_AllInternalCases`).
|
||||||
|
- The federal Rego agrees with the standard policy on every case where
|
||||||
|
no ancestor-deny intersects a leaf-allow, AND **disagrees** on the
|
||||||
|
cases where the AC-6 rule differs (`TestFederalRego_DivergencesFromStandard`).
|
||||||
|
This way both policies are guaranteed to behave as documented.
|
||||||
|
|
||||||
|
The test-only import means the production binary stays OPA-free (still
|
||||||
|
13 MB) — the OPA library is in `go.mod` but not in `go build`'s output.
|
||||||
|
|
||||||
This gives you both ends of the spectrum: a single OPA-aware codebase
|
This gives you both ends of the spectrum: a single OPA-aware codebase
|
||||||
where the production decider is pure Go (no library bloat, no extra
|
where the production decider is pure Go (no library bloat, no extra
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,21 @@ import (
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// --print-rego: dump the bundled reference Rego policy and exit.
|
// --print-rego: dump a bundled reference Rego policy and exit.
|
||||||
// Cheap escape hatch for operators standing up an external OPA who want
|
// Cheap escape hatch for operators standing up an external OPA who want
|
||||||
// the parity-tested baseline as a starting point for customization.
|
// a parity-tested baseline as a starting point for customization.
|
||||||
|
//
|
||||||
|
// --print-rego → standard cascade (commercial default)
|
||||||
|
// --print-rego=standard → same
|
||||||
|
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
|
||||||
for _, a := range os.Args[1:] {
|
for _, a := range os.Args[1:] {
|
||||||
if a == "--print-rego" {
|
switch a {
|
||||||
|
case "--print-rego", "--print-rego=standard":
|
||||||
fmt.Print(policy.ReferenceRego)
|
fmt.Print(policy.ReferenceRego)
|
||||||
return
|
return
|
||||||
|
case "--print-rego=federal":
|
||||||
|
fmt.Print(policy.FederalRego)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
179
zddc/internal/policy/federal_parity_test.go
Normal file
179
zddc/internal/policy/federal_parity_test.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
|
||||||
|
"github.com/open-policy-agent/opa/rego"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFederalRego_DivergencesFromStandard validates the federal-mode
|
||||||
|
// variant by asserting both that:
|
||||||
|
//
|
||||||
|
// (a) most cascade scenarios produce the same verdict as standard
|
||||||
|
// (the federal rule reduces to standard whenever no parent deny
|
||||||
|
// intersects a leaf allow), AND
|
||||||
|
//
|
||||||
|
// (b) the specific scenarios where the rules differ (a leaf-level
|
||||||
|
// allow overlaying an ancestor's deny) produce DIFFERENT verdicts:
|
||||||
|
// standard says allow (leaf wins); federal says deny (ancestor
|
||||||
|
// deny is absolute — NIST AC-6 default).
|
||||||
|
//
|
||||||
|
// Like the standard parity test, this imports the OPA library as a
|
||||||
|
// test-only dependency. The federal Rego is a deployable artifact
|
||||||
|
// (operators dump it via --print-rego=federal); the parity guard
|
||||||
|
// here proves the artifact behaves as documented.
|
||||||
|
func TestFederalRego_DivergencesFromStandard(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
standard, err := rego.New(
|
||||||
|
rego.Query("data.zddc.access.allow"),
|
||||||
|
rego.Module("access.rego", ReferenceRego),
|
||||||
|
).PrepareForEval(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile standard rego: %v", err)
|
||||||
|
}
|
||||||
|
federal, err := rego.New(
|
||||||
|
rego.Query("data.zddc.access_federal.allow"),
|
||||||
|
rego.Module("access_federal.rego", FederalRego),
|
||||||
|
).PrepareForEval(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile federal rego: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
|
||||||
|
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
|
||||||
|
empty := zddc.ZddcFile{}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
chain zddc.PolicyChain
|
||||||
|
email string
|
||||||
|
wantStandard bool
|
||||||
|
wantFederal bool
|
||||||
|
divergesByDesign bool // true if standard and federal must disagree here
|
||||||
|
}{
|
||||||
|
// ── Cases where the two policies must AGREE ────────────────
|
||||||
|
{
|
||||||
|
"empty chain, no files",
|
||||||
|
zddc.PolicyChain{HasAnyFile: false},
|
||||||
|
"alice@example.com",
|
||||||
|
true, true, false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files exist, no rule matches → both deny",
|
||||||
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
|
||||||
|
"alice@example.com",
|
||||||
|
false, false, false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"leaf allow with no ancestor deny → both allow",
|
||||||
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
|
||||||
|
"alice@example.com",
|
||||||
|
true, true, false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"only deny anywhere → both deny",
|
||||||
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com")}, HasAnyFile: true},
|
||||||
|
"alice@example.com",
|
||||||
|
false, false, false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob allow, no deny → both allow",
|
||||||
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true},
|
||||||
|
"alice@example.com",
|
||||||
|
true, true, false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── The signature divergence: leaf allow overlaying ancestor deny ──
|
||||||
|
{
|
||||||
|
"leaf allows what parent denied → standard allows, federal denies (AC-6)",
|
||||||
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||||
|
deny("alice@example.com"),
|
||||||
|
allow("alice@example.com"),
|
||||||
|
}, HasAnyFile: true},
|
||||||
|
"alice@example.com",
|
||||||
|
true, // standard: leaf wins
|
||||||
|
false, // federal: parent deny is absolute
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deep leaf re-allows after middle deny → standard allows, federal denies",
|
||||||
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||||
|
allow("*@example.com"),
|
||||||
|
deny("alice@example.com"),
|
||||||
|
allow("alice@example.com"),
|
||||||
|
}, HasAnyFile: true},
|
||||||
|
"alice@example.com",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob deny at root, specific allow at leaf → both differ",
|
||||||
|
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||||
|
deny("*@example.com"),
|
||||||
|
allow("alice@example.com"),
|
||||||
|
}, HasAnyFile: true},
|
||||||
|
"alice@example.com",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
input := AllowInput{Path: "/test", PolicyChain: chainToSerializable(tc.chain)}
|
||||||
|
input.User.Email = tc.email
|
||||||
|
regoInput, err := canonicalInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode input: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
std, err := standard.Eval(ctx, rego.EvalInput(regoInput))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("standard eval: %v", err)
|
||||||
|
}
|
||||||
|
fed, err := federal.Eval(ctx, rego.EvalInput(regoInput))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("federal eval: %v", err)
|
||||||
|
}
|
||||||
|
if len(std) == 0 || len(fed) == 0 {
|
||||||
|
t.Fatal("rego returned empty result set")
|
||||||
|
}
|
||||||
|
stdAllow := std[0].Expressions[0].Value.(bool)
|
||||||
|
fedAllow := fed[0].Expressions[0].Value.(bool)
|
||||||
|
|
||||||
|
if stdAllow != tc.wantStandard {
|
||||||
|
t.Errorf("standard rego: got %v, want %v", stdAllow, tc.wantStandard)
|
||||||
|
}
|
||||||
|
if fedAllow != tc.wantFederal {
|
||||||
|
t.Errorf("federal rego: got %v, want %v", fedAllow, tc.wantFederal)
|
||||||
|
}
|
||||||
|
// Cross-check the divergence flag itself: if we said the cases
|
||||||
|
// must disagree, they must; if we said they agree, they must.
|
||||||
|
diverges := stdAllow != fedAllow
|
||||||
|
if diverges != tc.divergesByDesign {
|
||||||
|
t.Errorf("divergence = %v, want %v (standard=%v, federal=%v)",
|
||||||
|
diverges, tc.divergesByDesign, stdAllow, fedAllow)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFederalRego_RegoCompiles is a sanity check that the embedded
|
||||||
|
// federal Rego file parses without error in OPA, separate from the
|
||||||
|
// behavior tests. Catches accidental syntax breakage in
|
||||||
|
// access_federal.rego before running the (slower) parity matrix.
|
||||||
|
func TestFederalRego_RegoCompiles(t *testing.T) {
|
||||||
|
_, err := rego.New(
|
||||||
|
rego.Query("data.zddc.access_federal.allow"),
|
||||||
|
rego.Module("access_federal.rego", FederalRego),
|
||||||
|
).PrepareForEval(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("federal rego does not compile: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,9 @@ import _ "embed"
|
||||||
// Customizations typical for federal deployments:
|
// Customizations typical for federal deployments:
|
||||||
//
|
//
|
||||||
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
|
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
|
||||||
// are absolute (NIST AC-6 least-privilege posture).
|
// are absolute (NIST AC-6 least-privilege posture). For this specific
|
||||||
|
// case zddc-server ships a parity-tested federal-mode variant; see
|
||||||
|
// FederalRego and `--print-rego=federal`.
|
||||||
// - Add role-based access via additional input fields (input.user.roles
|
// - Add role-based access via additional input fields (input.user.roles
|
||||||
// populated by the upstream proxy from SAML/OIDC claims).
|
// populated by the upstream proxy from SAML/OIDC claims).
|
||||||
// - Add time-of-day or IP-range constraints.
|
// - Add time-of-day or IP-range constraints.
|
||||||
|
|
@ -24,3 +26,21 @@ import _ "embed"
|
||||||
//
|
//
|
||||||
//go:embed rego/access.rego
|
//go:embed rego/access.rego
|
||||||
var ReferenceRego string
|
var ReferenceRego string
|
||||||
|
|
||||||
|
// FederalRego is the strict-least-privilege variant of ReferenceRego
|
||||||
|
// where parent denies are absolute (NIST AC-6). Drop-in for federal
|
||||||
|
// customers who need the AC-6 posture without writing Rego from
|
||||||
|
// scratch:
|
||||||
|
//
|
||||||
|
// zddc-server --print-rego=federal > /etc/opa/policies/zddc-access.rego
|
||||||
|
//
|
||||||
|
// The internal Go evaluator does NOT implement these semantics — it
|
||||||
|
// stays on the commercial cascade. Federal-mode is reachable only by
|
||||||
|
// running OPA with this policy and pointing ZDDC_OPA_URL at it. See
|
||||||
|
// zddc/internal/policy/rego/access_federal.rego for the policy itself
|
||||||
|
// and federal_parity_test.go for the divergence-test fixtures (cases
|
||||||
|
// where federal-mode and commercial-mode disagree, asserting each gives
|
||||||
|
// the expected verdict).
|
||||||
|
//
|
||||||
|
//go:embed rego/access_federal.rego
|
||||||
|
var FederalRego string
|
||||||
|
|
|
||||||
95
zddc/internal/policy/rego/access_federal.rego
Normal file
95
zddc/internal/policy/rego/access_federal.rego
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Federal-mode reference policy: parent-deny-is-absolute (NIST AC-6).
|
||||||
|
#
|
||||||
|
# This is a strict-least-privilege variant of access.rego. The two policies
|
||||||
|
# differ in exactly one rule, but the semantic difference is meaningful for
|
||||||
|
# federal evaluators:
|
||||||
|
#
|
||||||
|
# access.rego (commercial, default):
|
||||||
|
# "Bottom-up walk; first explicit match wins; deny-first within a level.
|
||||||
|
# A leaf-level allow CAN override an ancestor's deny."
|
||||||
|
# Test: cascade_test.go "leaf allows user that parent denies → leaf wins".
|
||||||
|
#
|
||||||
|
# access_federal.rego (federal):
|
||||||
|
# "Any deny anywhere along the chain is absolute. An allow only matters
|
||||||
|
# if no ancestor (or sibling level) has denied the same email. Leaf-
|
||||||
|
# level allows do NOT override ancestor denies."
|
||||||
|
# Required by NIST AC-6 (Least Privilege) default expectations: a
|
||||||
|
# central admin's deny at the root must be unbypassable by a tenant
|
||||||
|
# who controls a subtree's .zddc.
|
||||||
|
#
|
||||||
|
# Why ship two policies? The internal Go evaluator (in zddc/internal/zddc/
|
||||||
|
# acl.go) implements only the commercial cascade — it's the rule the
|
||||||
|
# default deployment exercises. Federal customers running their own OPA
|
||||||
|
# with this file get the strict variant without any zddc-server code
|
||||||
|
# change. They can also write a hybrid policy (e.g. "deny is absolute
|
||||||
|
# only for emails matching some pattern; cascade rules for everyone
|
||||||
|
# else") since once they're hosting their own OPA, the constraint is
|
||||||
|
# whatever they write.
|
||||||
|
#
|
||||||
|
# Input shape: identical to access.rego — see that file's docstring.
|
||||||
|
|
||||||
|
package zddc.access_federal
|
||||||
|
|
||||||
|
import future.keywords.if
|
||||||
|
import future.keywords.in
|
||||||
|
|
||||||
|
default allow := false
|
||||||
|
|
||||||
|
# Allow when no .zddc files exist anywhere AND no rule matches.
|
||||||
|
# Same default-allow case as commercial; preserves the empty-tree
|
||||||
|
# behaviour. (zddc-server's --insecure check at startup makes this
|
||||||
|
# unreachable in any non-deliberately-public deployment.)
|
||||||
|
allow if {
|
||||||
|
not input.policy_chain.has_any_file
|
||||||
|
not any_deny_match
|
||||||
|
not any_allow_match
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allow when files exist, no level (any depth) denies, and at least
|
||||||
|
# one level allows. The "any level" check is what makes parent denies
|
||||||
|
# absolute — there is no "deepest match wins" rule here.
|
||||||
|
allow if {
|
||||||
|
input.policy_chain.has_any_file
|
||||||
|
not any_deny_match
|
||||||
|
any_allow_match
|
||||||
|
}
|
||||||
|
|
||||||
|
# Any deny pattern at ANY level matches the email.
|
||||||
|
any_deny_match if {
|
||||||
|
some level in input.policy_chain.levels
|
||||||
|
some pattern in level.acl.deny
|
||||||
|
email_matches(pattern, input.user.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Any allow pattern at ANY level matches the email.
|
||||||
|
any_allow_match if {
|
||||||
|
some level in input.policy_chain.levels
|
||||||
|
some pattern in level.acl.allow
|
||||||
|
email_matches(pattern, input.user.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
# email_matches: identical to access.rego — see that file for the
|
||||||
|
# rationale on the four cases. Duplicated rather than imported so this
|
||||||
|
# file is self-contained for operators who copy it as a starting point.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue