chore(server): drop the federal reference Rego (bring-your-own-policy)
Decision: external OPA is a bring-your-own-policy escape hatch, not a supported turnkey mode — so stop shipping access_federal.rego. A verb-blind read-ACL policy under NIST AC-6 branding is a liability to hand a federal evaluator, and (like access.rego before the fail-close) it over-granted writes and ignored WORM. The HTTPDecider + Decider interface stay: operators who want an AC-6 ancestor-deny-absolute posture write their own Rego. - Delete rego/access_federal.rego, FederalRego, --print-rego=federal, and federal_parity_test.go; trim the federal cases from rego_failclosed_test.go. - Reframe every doc reference (rego.go, main.go, file.go, ARCHITECTURE.md, README.md) to "operators write their own Rego"; rewrite the README "Reference Rego policy" section to describe the single fail-closed read-ACL skeleton accurately (it also still carried the now-removed "mirrors exactly" parity claim). Out of scope (flagged): the broader federal-readiness narrative (FedRAMP/FIPS/IdP) and the separate website page federal.html still discuss federal posture — the OPA bring-your-own-Rego path stays valid, but a deliberate review with the federal go-to-market in mind is warranted. go vet + full go test ./... green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84c1b58b66
commit
6d132572d3
8 changed files with 80 additions and 427 deletions
|
|
@ -544,7 +544,7 @@ none of them is load-bearing alone.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
|
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
|
||||||
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
||||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in default tree bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego (the bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data |
|
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in default tree bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego the operator writes (e.g. ancestor-deny-absolute for NIST AC-6) while keeping the same `.zddc` files as input data; zddc-server ships only a fail-closed read-ACL skeleton (`--print-rego`) as a starting point |
|
||||||
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into the embedded default tree): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; the embedded default tree |
|
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into the embedded default tree): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; the embedded default tree |
|
||||||
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
|
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
|
||||||
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
|
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
|
||||||
|
|
@ -700,7 +700,7 @@ whether to deploy the system should know which column they're in.
|
||||||
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
|
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
|
||||||
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
|
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
|
||||||
| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) |
|
| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) |
|
||||||
| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with `access_federal.rego` for ancestor-deny-absolute / NIST AC-6 | (closed; federal posture is the OPA path) |
|
| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with the operator's own ancestor-deny-absolute Rego (NIST AC-6) | (closed; federal posture is the OPA path) |
|
||||||
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
|
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
|
||||||
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
|
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
|
||||||
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
|
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
|
||||||
|
|
@ -723,7 +723,7 @@ Five permission verbs gate every read and write:
|
||||||
|
|
||||||
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny.
|
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny.
|
||||||
|
|
||||||
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with the bundled `access_federal.rego`.
|
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with their own Rego (zddc-server ships only the fail-closed read-ACL skeleton at `--print-rego`).
|
||||||
|
|
||||||
The `admins:` field (root or any subtree `.zddc`) confers admin authority over that level and below, but it splits into two powers — see the elevation section below:
|
The `admins:` field (root or any subtree `.zddc`) confers admin authority over that level and below, but it splits into two powers — see the elevation section below:
|
||||||
- **Standing config-edit (no elevation):** an admin — or anyone with the `a` verb — may edit the `.zddc`/roles of subtrees they administer. `IsConfigEditor` grants `VerbA` above the WORM clamp; it owns the subtree's policy but cannot write/delete records.
|
- **Standing config-edit (no elevation):** an admin — or anyone with the `a` verb — may edit the `.zddc`/roles of subtrees they administer. `IsConfigEditor` grants `VerbA` above the WORM clamp; it owns the subtree's policy but cannot write/delete records.
|
||||||
|
|
|
||||||
111
zddc/README.md
111
zddc/README.md
|
|
@ -433,8 +433,7 @@ fence is computed by `PolicyChain.VisibleStart`.
|
||||||
|
|
||||||
The leaf-overrides-ancestor behaviour above is the in-process decider's only
|
The leaf-overrides-ancestor behaviour above is the in-process decider's only
|
||||||
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
|
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
|
||||||
OPA with the bundled `access_federal.rego` (or their own Rego); see
|
OPA with their own Rego; see "External OPA" below.
|
||||||
"External OPA" below.
|
|
||||||
|
|
||||||
#### The `inherit:` directive
|
#### The `inherit:` directive
|
||||||
|
|
||||||
|
|
@ -471,10 +470,10 @@ Behaviour:
|
||||||
fence; `inherit: false` does not change WORM behaviour. See
|
fence; `inherit: false` does not change WORM behaviour. See
|
||||||
"Canonical-folder behaviour via `.zddc` keys" below.
|
"Canonical-folder behaviour via `.zddc` keys" below.
|
||||||
|
|
||||||
**Federal posture and `inherit: false`.** The bundled federal Rego at
|
**Federal posture and `inherit: false`.** An external OPA policy with
|
||||||
`--print-rego=federal` makes ancestor explicit-denies absolute and
|
ancestor-deny-absolute (NIST AC-6) semantics makes ancestor explicit-denies
|
||||||
therefore ignores `inherit: false` (allowing a leaf to widen access an
|
absolute and therefore ignores `inherit: false` (allowing a leaf to widen
|
||||||
ancestor refused would defeat NIST AC-6). Operators who need fence-
|
access an ancestor refused would defeat NIST AC-6). Operators who need fence-
|
||||||
style "reset" semantics in a federal-track deployment should not use
|
style "reset" semantics in a federal-track deployment should not use
|
||||||
the directive — instead, restructure the tree so the permissive
|
the directive — instead, restructure the tree so the permissive
|
||||||
ancestor rule never appears.
|
ancestor rule never appears.
|
||||||
|
|
@ -927,13 +926,14 @@ have to redo the gap analysis from scratch.
|
||||||
Identity-source-driven role assignment plumbs through unchanged
|
Identity-source-driven role assignment plumbs through unchanged
|
||||||
(the upstream proxy still asserts the email; role membership is
|
(the upstream proxy still asserts the email; role membership is
|
||||||
evaluated server-side against the cascade).
|
evaluated server-side against the cascade).
|
||||||
- ~~**Least-privilege bounding** (NIST AC-6)~~ — *closed.* Operators
|
- ~~**Least-privilege bounding** (NIST AC-6)~~ — *available via the OPA
|
||||||
deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego
|
path.* Operators deploy OPA (`ZDDC_OPA_URL`) pointed at their own
|
||||||
(`zddc-server --print-rego=federal`) or their own variant. Under
|
ancestor-deny-absolute Rego, under which any ancestor explicit-deny is
|
||||||
that policy any ancestor explicit-deny is absolute and cannot be
|
absolute and cannot be overridden by a leaf grant. The in-process Go
|
||||||
overridden by a leaf grant. The in-process Go evaluator implements
|
evaluator implements only the commercial "leaf grants override ancestor
|
||||||
only the commercial "leaf grants override ancestor denies" rule;
|
denies" rule, and the bundled `--print-rego` skeleton models read-ACL
|
||||||
federal posture is exclusively the OPA path.
|
only (fail-closed for writes) — an AC-6 federal policy is the operator's
|
||||||
|
own Rego, not a shipped artifact.
|
||||||
- **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
|
||||||
|
|
@ -1266,56 +1266,47 @@ cache lookup would be.
|
||||||
|
|
||||||
### Reference Rego policy
|
### Reference Rego policy
|
||||||
|
|
||||||
The `--print-rego` flag emits the bundled reference Rego policies. Two
|
The `--print-rego` flag emits the bundled reference Rego **skeleton**:
|
||||||
variants ship:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
zddc-server --print-rego # standard cascade (commercial)
|
zddc-server --print-rego # read-ACL skeleton (fail-closed)
|
||||||
zddc-server --print-rego=standard # same
|
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-
|
This skeleton models the **read-ACL cascade only** — glob patterns,
|
||||||
level allows can override an ancestor's deny (the cascade's intentional
|
deny-first-within-a-level, default-deny once any `.zddc` exists, and the
|
||||||
delegation property). The federal variant is the strict-least-privilege
|
leaf-allow-overrides-ancestor-deny delegation property. It is **NOT** a
|
||||||
posture: any deny anywhere in the chain is absolute, no leaf-level
|
semantic mirror of the internal Go decider: it does not implement per-verb
|
||||||
override possible. Federal customers running their own OPA can drop
|
authorization (write/create/delete/admin), WORM zones, `roles:` resolution,
|
||||||
the federal Rego in unchanged, or use either as a starting point for
|
`inherit:false` fences, or standing config-edit. Because those are
|
||||||
further customization.
|
unmodelled it is **fail-closed** — every non-read action is denied, and an
|
||||||
|
elevated admin (`input.user.is_active_admin`) is the only write-capable
|
||||||
|
principal. **Treat it as a starting point, not a turnkey policy:** an
|
||||||
|
operator relying on external OPA for write authorization must add the
|
||||||
|
missing semantics (and, for a NIST AC-6 ancestor-deny-absolute posture,
|
||||||
|
write that rule) before granting writes.
|
||||||
|
|
||||||
Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
|
A build-time guard (`zddc/internal/policy/parity_test.go`,
|
||||||
imports the OPA Go module **as a test-only dependency**, evaluates both
|
`rego_failclosed_test.go`) imports the OPA Go module **as a test-only
|
||||||
bundled Regos against fixture sets and asserts:
|
dependency** and asserts the skeleton matches the internal Go evaluator on
|
||||||
|
the read-cascade dimension (`TestRegoParity_AllInternalCases`) and denies
|
||||||
|
every write verb (`TestReferenceRego_FailClosedOnWrites`). This is a
|
||||||
|
read-cascade + fail-closed guard, **not** a full-parity proof. The
|
||||||
|
test-only import means the production binary stays OPA-free (~13 MB) — the
|
||||||
|
OPA library is in `go.mod` but not in `go build`'s output.
|
||||||
|
|
||||||
- The standard Rego matches the internal Go evaluator on every documented
|
The production decider is pure Go (no library bloat, no extra process); the
|
||||||
cascade scenario (`TestRegoParity_AllInternalCases`).
|
wire format is OPA-canonical, so an operator can point an external OPA at it
|
||||||
- The federal Rego agrees with the standard policy on every case where
|
and extend the skeleton. Typical extensions an operator writes on top:
|
||||||
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
|
- **Per-verb + WORM + roles + config-edit** — the semantics the skeleton
|
||||||
13 MB) — the OPA library is in `go.mod` but not in `go build`'s output.
|
omits; required before the policy can authorize writes at all.
|
||||||
|
- **Parent-deny-is-absolute** — make any ancestor deny absolute for a NIST
|
||||||
This gives you both ends of the spectrum: a single OPA-aware codebase
|
AC-6 least-privilege posture.
|
||||||
where the production decider is pure Go (no library bloat, no extra
|
|
||||||
process), the wire format is OPA-canonical (just point an external OPA
|
|
||||||
at it and decisions delegate seamlessly), and the bundled reference
|
|
||||||
Rego is a parity-tested artifact you can ship alongside or extend.
|
|
||||||
|
|
||||||
Typical federal customizations on top of the bundled Rego:
|
|
||||||
|
|
||||||
- **Parent-deny-is-absolute** — flip the leaf-allow-overrides-parent-deny
|
|
||||||
rule for NIST AC-6 least-privilege posture.
|
|
||||||
- **Role-based access** — read additional input fields like
|
- **Role-based access** — read additional input fields like
|
||||||
`input.user.roles` populated by the upstream proxy from SAML/OIDC
|
`input.user.roles` populated by the upstream proxy from SAML/OIDC claims.
|
||||||
claims, and decide based on those instead of (or alongside) email.
|
- **Time-of-day or IP-range constraints**, and **SIEM-shipped decision
|
||||||
- **Time-of-day or IP-range constraints** — Rego can read
|
logs** via OPA's logging plugins (Splunk Government, Elastic Federal, etc.).
|
||||||
`input.context.now` and request metadata for context-aware
|
|
||||||
decisions.
|
|
||||||
- **SIEM-shipped decision logs** — OPA's logging plugins emit every
|
|
||||||
decision in a structured format ready for Splunk Government, Elastic
|
|
||||||
Federal, etc.
|
|
||||||
|
|
||||||
### Reference deployment shapes
|
### Reference deployment shapes
|
||||||
|
|
||||||
|
|
@ -1326,7 +1317,7 @@ No sidecar, no extra port, no extra binary.
|
||||||
**Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar,
|
**Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar,
|
||||||
nomad task, or systemd service on the same host), bind it to
|
nomad task, or systemd service on the same host), bind it to
|
||||||
`127.0.0.1:8181` (or a Unix socket), point `ZDDC_OPA_URL` at it. OPA
|
`127.0.0.1:8181` (or a Unix socket), point `ZDDC_OPA_URL` at it. OPA
|
||||||
loads the deployment's bundled Rego policy from a configured source
|
loads the deployment's own Rego policy from a configured source
|
||||||
(filesystem, signed bundle from S3, OPAL, etc.) and is patched
|
(filesystem, signed bundle from S3, OPAL, etc.) and is patched
|
||||||
independently of zddc-server.
|
independently of zddc-server.
|
||||||
|
|
||||||
|
|
@ -1350,10 +1341,12 @@ gaps that warrant code, in addition to the federal-readiness items above:
|
||||||
CM-3 federal control above).
|
CM-3 federal control above).
|
||||||
- Per-decision caching for external OPA mode (small TTL on (email, path)
|
- Per-decision caching for external OPA mode (small TTL on (email, path)
|
||||||
to amortize the .archive listing's per-entry round-trip).
|
to amortize the .archive listing's per-entry round-trip).
|
||||||
- A reference Rego bundle shipped alongside the binary that exactly
|
- A full-parity reference Rego (modelling per-verb / WORM / roles /
|
||||||
reproduces internal mode, plus a "federal-mode" variant that flips
|
config-edit, not just the read-ACL skeleton shipped today) plus a
|
||||||
the parent-deny-is-absolute toggle. Useful as a starting point for
|
generative differential test against the internal decider — only worth
|
||||||
customers who want to extend rather than write from scratch.
|
building if external OPA becomes a *supported deployment mode* rather than
|
||||||
|
a bring-your-own-policy escape hatch. See the skeleton's caveats under
|
||||||
|
"Reference Rego policy."
|
||||||
|
|
||||||
## Admin Debug Page
|
## Admin Debug Page
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,21 +36,18 @@ import (
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// --print-rego: dump a bundled reference Rego policy and exit.
|
// --print-rego: dump the bundled reference Rego skeleton and exit.
|
||||||
// Cheap escape hatch for operators standing up an external OPA who want
|
// A starting point for operators standing up an external OPA: it models
|
||||||
// a parity-tested baseline as a starting point for customization.
|
// the read-ACL cascade only and is fail-closed for writes (NOT a mirror
|
||||||
|
// of the internal decider), so it must be extended before granting writes.
|
||||||
//
|
//
|
||||||
// --print-rego → standard cascade (commercial default)
|
// --print-rego → read-ACL skeleton (fail-closed)
|
||||||
// --print-rego=standard → same
|
// --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:] {
|
||||||
switch a {
|
switch a {
|
||||||
case "--print-rego", "--print-rego=standard":
|
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
|
|
||||||
case "show-defaults", "--show-defaults":
|
case "show-defaults", "--show-defaults":
|
||||||
// Emit the embedded baseline as a .zddc.zip (per-depth policy
|
// Emit the embedded baseline as a .zddc.zip (per-depth policy
|
||||||
// tree, "*" wildcard members) to stdout. Redirect into a bundle
|
// tree, "*" wildcard members) to stdout. Redirect into a bundle
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
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 {
|
|
||||||
m := make(map[string]string, len(p))
|
|
||||||
for _, x := range p {
|
|
||||||
m[x] = "rwcd"
|
|
||||||
}
|
|
||||||
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
|
|
||||||
}
|
|
||||||
deny := func(p ...string) zddc.ZddcFile {
|
|
||||||
m := make(map[string]string, len(p))
|
|
||||||
for _, x := range p {
|
|
||||||
m[x] = ""
|
|
||||||
}
|
|
||||||
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,9 +21,7 @@ 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). For this specific
|
// are absolute (NIST AC-6 least-privilege posture).
|
||||||
// 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.
|
||||||
|
|
@ -32,21 +30,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
# Federal-mode reference SKELETON: parent-deny-is-absolute (NIST AC-6),
|
|
||||||
# read-ACL only.
|
|
||||||
#
|
|
||||||
# Like access.rego this models the read cascade ONLY and is NOT a complete
|
|
||||||
# authorization policy — it does not implement per-verb (write/create/delete/
|
|
||||||
# admin), WORM, roles, inherit:false fences, or config-edit. It is therefore
|
|
||||||
# FAIL-CLOSED: every non-read action is denied. This variant deliberately has
|
|
||||||
# NO admin bypass either — under AC-6 least-privilege the default posture is
|
|
||||||
# deny, and an operator who needs a write path must add the per-verb (and, if
|
|
||||||
# desired, admin-escape) semantics themselves. As shipped it authorizes reads
|
|
||||||
# only.
|
|
||||||
#
|
|
||||||
# The ONE modelled difference from access.rego: any deny anywhere on the chain
|
|
||||||
# is absolute — a leaf-level allow does NOT override an ancestor's deny.
|
|
||||||
# Required by NIST AC-6: a central admin's root deny must be unbypassable by
|
|
||||||
# a tenant who controls a subtree's .zddc.
|
|
||||||
# access.rego (commercial): leaf allow CAN override an ancestor deny.
|
|
||||||
# access_federal.rego: ancestor deny is absolute.
|
|
||||||
#
|
|
||||||
# The internal Go evaluator implements neither these federal semantics nor a
|
|
||||||
# tested mirror of this file; federal-mode is reachable only by running OPA
|
|
||||||
# with this policy and pointing ZDDC_OPA_URL at it. See federal_parity_test.go
|
|
||||||
# for the modelled read-cascade divergence fixtures.
|
|
||||||
#
|
|
||||||
# Input shape: identical to access.rego — see that file's docstring.
|
|
||||||
# acl.permissions maps principal patterns to verb strings; an empty
|
|
||||||
# verb string is an explicit deny.
|
|
||||||
|
|
||||||
package zddc.access_federal
|
|
||||||
|
|
||||||
import future.keywords.if
|
|
||||||
import future.keywords.in
|
|
||||||
|
|
||||||
default allow := false
|
|
||||||
|
|
||||||
# Read-ACL only: every grant rule is gated on a read action; any write/
|
|
||||||
# create/delete/admin falls through to the default-deny above (fail-closed).
|
|
||||||
# Empty/absent action == read. (No admin bypass in federal mode — see header.)
|
|
||||||
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 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 {
|
|
||||||
is_read_action
|
|
||||||
not input.policy_chain.has_any_file
|
|
||||||
not any_deny_match
|
|
||||||
not any_allow_match
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read allowed 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 {
|
|
||||||
is_read_action
|
|
||||||
input.policy_chain.has_any_file
|
|
||||||
not any_deny_match
|
|
||||||
any_allow_match
|
|
||||||
}
|
|
||||||
|
|
||||||
# Any explicit-deny permission entry at ANY level matches the email.
|
|
||||||
any_deny_match if {
|
|
||||||
some level in input.policy_chain.levels
|
|
||||||
some pattern, verbs in level.acl.permissions
|
|
||||||
verbs == ""
|
|
||||||
email_matches(pattern, input.user.email)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Any grant permission entry (non-empty verbs) at ANY level matches.
|
|
||||||
any_allow_match if {
|
|
||||||
some level in input.policy_chain.levels
|
|
||||||
some pattern, verbs in level.acl.permissions
|
|
||||||
verbs != ""
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -10,30 +10,21 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestReferenceRego_FailClosedOnWrites pins the security contract of the
|
// TestReferenceRego_FailClosedOnWrites pins the security contract of the
|
||||||
// bundled reference Rego skeletons: they model READ-ACL only, so any non-read
|
// bundled reference Rego skeleton: it models READ-ACL only, so any non-read
|
||||||
// action must be DENIED even when the read-ACL would grant — and the commercial
|
// action must be DENIED even when the read-ACL would grant — and the only
|
||||||
// variant's only write-capable principal is an elevated admin. This is the
|
// write-capable principal is an elevated admin. This is the behavior that,
|
||||||
// behavior that, untested, previously let a verb-blind policy ship claiming to
|
// untested, previously let a verb-blind policy ship claiming to "mirror the
|
||||||
// "mirror the internal decider exactly." See rego/access.rego.
|
// internal decider exactly." See rego/access.rego.
|
||||||
func TestReferenceRego_FailClosedOnWrites(t *testing.T) {
|
func TestReferenceRego_FailClosedOnWrites(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
mkQuery := func(module, src string) rego.PreparedEvalQuery {
|
stdQ, err := rego.New(
|
||||||
var pkg string
|
rego.Query("data.zddc.access.allow"),
|
||||||
switch module {
|
rego.Module("access.rego", ReferenceRego),
|
||||||
case "access.rego":
|
).PrepareForEval(ctx)
|
||||||
pkg = "data.zddc.access.allow"
|
|
||||||
default:
|
|
||||||
pkg = "data.zddc.access_federal.allow"
|
|
||||||
}
|
|
||||||
q, err := rego.New(rego.Query(pkg), rego.Module(module, src)).PrepareForEval(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compile %s: %v", module, err)
|
t.Fatalf("compile access.rego: %v", err)
|
||||||
}
|
}
|
||||||
return q
|
|
||||||
}
|
|
||||||
stdQ := mkQuery("access.rego", ReferenceRego)
|
|
||||||
fedQ := mkQuery("access_federal.rego", FederalRego)
|
|
||||||
|
|
||||||
// A chain that GRANTS full rwcd to alice — so any denial below is the
|
// A chain that GRANTS full rwcd to alice — so any denial below is the
|
||||||
// action gate, not a missing ACL.
|
// action gate, not a missing ACL.
|
||||||
|
|
@ -78,15 +69,9 @@ func TestReferenceRego_FailClosedOnWrites(t *testing.T) {
|
||||||
{"access create denied", stdQ, ActionCreate, false, false},
|
{"access create denied", stdQ, ActionCreate, false, false},
|
||||||
{"access delete denied", stdQ, ActionDelete, false, false},
|
{"access delete denied", stdQ, ActionDelete, false, false},
|
||||||
{"access admin-action denied", stdQ, ActionAdmin, false, false},
|
{"access admin-action denied", stdQ, ActionAdmin, false, false},
|
||||||
// Commercial: an elevated admin is the one write-capable principal.
|
// An elevated admin is the one write-capable principal.
|
||||||
{"access write allowed for active admin", stdQ, ActionWrite, true, true},
|
{"access write allowed for active admin", stdQ, ActionWrite, true, true},
|
||||||
{"access delete allowed for active admin", stdQ, ActionDelete, true, true},
|
{"access delete allowed for active admin", stdQ, ActionDelete, true, true},
|
||||||
|
|
||||||
// Federal: reads granted, every write denied, and NO admin bypass.
|
|
||||||
{"federal read allowed", fedQ, ActionRead, false, true},
|
|
||||||
{"federal write denied", fedQ, ActionWrite, false, false},
|
|
||||||
{"federal admin-action denied", fedQ, ActionAdmin, false, false},
|
|
||||||
{"federal write denied even for active admin", fedQ, ActionWrite, true, false},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,13 @@ import (
|
||||||
// (e.g. a vendor folder where only the vendor and the doc controller
|
// (e.g. a vendor folder where only the vendor and the doc controller
|
||||||
// should have access regardless of broader project-level grants).
|
// should have access regardless of broader project-level grants).
|
||||||
//
|
//
|
||||||
// Federal deployments running the bundled `access_federal.rego` get
|
// A deployment running an external OPA with ancestor-deny-absolute
|
||||||
// parent-deny-is-absolute / NIST AC-6 semantics; the directive's
|
// (NIST AC-6) semantics should avoid the directive's fence-style "reset",
|
||||||
// fence-style "reset" should be avoided there because it would let a
|
// since under that posture it would let a leaf widen access an ancestor
|
||||||
// leaf widen access an ancestor refused. The cascade tracer at
|
// refused. (zddc-server ships only the read-ACL skeleton at --print-rego;
|
||||||
// /.profile/effective-policy reports `chain.visible_start` so an
|
// an AC-6 policy is the operator's own Rego.) The cascade tracer at
|
||||||
// operator can verify which level a fence is actually cutting off.
|
// /.profile/effective-policy reports `chain.visible_start` so an operator
|
||||||
|
// can verify which level a fence is actually cutting off.
|
||||||
//
|
//
|
||||||
// Inherit is per-level and not itself cascading: an ancestor's
|
// Inherit is per-level and not itself cascading: an ancestor's
|
||||||
// `inherit: false` does not transitively block descendants from
|
// `inherit: false` does not transitively block descendants from
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue