Some checks failed
ci / standards-gate (push) Has been cancelled
Adopt the org engineering standard (its-consulting/standards @ v1.0.0).
Adds baseline governance/CI/policy files rendered from the standard's
templates and pins .standards-version=1.0.0. Vendored OPA/Rego policies
under .standards/policies/ so CI runs the gate locally (no cross-repo dep).
Placeholders ({{ORG}}/{{REPO}}/{{OWNER_HANDLE}}/{{MAINTAINER_EMAIL}}) filled in.
Existing files that differ were left untouched by the adopter.
Automated rollout. Files created: 15.
136 lines
4.9 KiB
Rego
136 lines
4.9 KiB
Rego
# Tenant (Mandant) separation: a deploy/manifest artifact belonging to one Mandant
|
|
# must never reference another Mandant's resources, paths, or age recipients.
|
|
#
|
|
# Enforces: docs/adr/0006-multi-tenant-separation.md
|
|
# SOP: sops/SOP-005-secrets-management.md (per-Mandant SOPS+age key sets)
|
|
# Input: a deploy/manifest descriptor carrying the owning Mandant and the
|
|
# resources it references:
|
|
# {
|
|
# "mandant": "gmbh-a",
|
|
# "resources": ["secrets-gmbh-a/db.env", "ops-gmbh-a/deploy.yml"],
|
|
# "paths": ["/srv/gmbh-a/data"],
|
|
# "age_recipients": ["age1aaa...gmbh-a-key"]
|
|
# }
|
|
# `resources` and `paths` are referenced artifact paths; `age_recipients` are the
|
|
# age public keys a SOPS-encrypted secret is sealed to.
|
|
#
|
|
# Model: every Mandant owns a namespace token `gmbh-<x>`. A reference is cross-tenant
|
|
# when it names ANOTHER Mandant's namespace (e.g. a `gmbh-b` path in a `gmbh-a`
|
|
# artifact) or an age recipient that is not in this Mandant's allowed recipient set.
|
|
#
|
|
# Rules (default-deny cross-tenant):
|
|
# deny - the artifact declares no Mandant (cannot be attributed -> reject)
|
|
# deny - a referenced resource/path names a foreign Mandant namespace
|
|
# deny - an age recipient is not on this Mandant's allowed set
|
|
package standards.ci.tenant
|
|
|
|
import rego.v1
|
|
|
|
import data.standards.lib
|
|
|
|
# Only evaluate inputs that are actually tenant descriptors (carry a Mandant marker).
|
|
# This keeps the package quiet under conftest --all-namespaces for unrelated inputs
|
|
# (k8s/compose/Quadlet objects), while still default-denying an unattributed artifact
|
|
# that DOES look like a tenant descriptor (has resources/paths/age_recipients).
|
|
_is_tenant_input if {
|
|
lib.has_key(input, "mandant")
|
|
}
|
|
|
|
_is_tenant_input if {
|
|
lib.has_key(input, "resources")
|
|
}
|
|
|
|
_is_tenant_input if {
|
|
lib.has_key(input, "age_recipients")
|
|
}
|
|
|
|
# All known Mandant namespace tokens. Grounded in ADR-0006's example tenants; the
|
|
# set is the universe of foreign namespaces a reference may accidentally point at.
|
|
known_mandanten := {"gmbh-a", "gmbh-b"}
|
|
|
|
# The owning Mandant of this artifact, lower-cased, or "" when absent/empty.
|
|
# object.get with a default keeps this total even when the key is missing entirely,
|
|
# so the `mandant == ""` default-deny below fires for an unattributed artifact.
|
|
mandant := m if {
|
|
raw := object.get(input, "mandant", "")
|
|
lib.non_empty_string(raw)
|
|
m := lower(raw)
|
|
}
|
|
|
|
mandant := "" if {
|
|
raw := object.get(input, "mandant", "")
|
|
not lib.non_empty_string(raw)
|
|
}
|
|
|
|
# Allowed age recipients for the owning Mandant (from input.allowed_age_recipients,
|
|
# keyed by Mandant). When the artifact provides no allow-list we cannot vouch for any
|
|
# recipient, so every declared recipient is treated as foreign (default-deny).
|
|
allowed_recipients := r if {
|
|
all := object.get(input, "allowed_age_recipients", {})
|
|
r := object.get(all, mandant, [])
|
|
}
|
|
|
|
# Every referenced path/resource string, collected from the supported fields.
|
|
references contains ref if {
|
|
ref := object.get(input, "resources", [])[_]
|
|
}
|
|
|
|
references contains ref if {
|
|
ref := object.get(input, "paths", [])[_]
|
|
}
|
|
|
|
# --- Default-deny: an unattributed artifact ---------------------------------
|
|
|
|
deny contains msg if {
|
|
_is_tenant_input
|
|
mandant == ""
|
|
msg := "artifact declares no 'mandant'; cross-tenant separation cannot be enforced — every Mandant artifact must be attributed (ADR-0006, SOP-005)"
|
|
}
|
|
|
|
# --- Cross-tenant reference: a foreign Mandant namespace --------------------
|
|
|
|
deny contains msg if {
|
|
_is_tenant_input
|
|
mandant != ""
|
|
some ref in references
|
|
is_string(ref)
|
|
some foreign in known_mandanten
|
|
foreign != mandant
|
|
_references_namespace(ref, foreign)
|
|
msg := sprintf("mandant '%v' artifact references foreign tenant resource '%v' (matches '%v'); tenants must not share resources (ADR-0006, SOP-005)", [mandant, ref, foreign])
|
|
}
|
|
|
|
# --- Cross-tenant age recipient: not on this Mandant's allowed set ----------
|
|
|
|
deny contains msg if {
|
|
_is_tenant_input
|
|
mandant != ""
|
|
some rcpt in object.get(input, "age_recipients", [])
|
|
is_string(rcpt)
|
|
not _recipient_allowed(rcpt)
|
|
msg := sprintf("mandant '%v' seals a secret to age recipient '%v' which is not in its allowed recipient set; each Mandant must use only its own age keys (ADR-0006, SOP-005)", [mandant, rcpt])
|
|
}
|
|
|
|
# --- helpers ----------------------------------------------------------------
|
|
|
|
# _references_namespace(ref, ns) is true when a path/resource string carries the
|
|
# foreign Mandant namespace, either as a `secrets-<ns>` / `ops-<ns>` prefix or as a
|
|
# path segment `<ns>` anywhere in the reference.
|
|
_references_namespace(ref, ns) if {
|
|
contains(lower(ref), sprintf("secrets-%v", [ns]))
|
|
}
|
|
|
|
_references_namespace(ref, ns) if {
|
|
contains(lower(ref), sprintf("ops-%v", [ns]))
|
|
}
|
|
|
|
_references_namespace(ref, ns) if {
|
|
parts := split(lower(ref), "/")
|
|
parts[_] == ns
|
|
}
|
|
|
|
# _recipient_allowed(rcpt) is true when the age recipient is on this Mandant's set.
|
|
_recipient_allowed(rcpt) if {
|
|
some allowed in allowed_recipients
|
|
allowed == rcpt
|
|
}
|