Skip to content

ADR-0104: Security model — secrets, TLS, RBAC, and redaction

The consolidated threat model and security posture: how credentials, TLS trust, least-privilege RBAC, and payload redaction are handled across Kollect.

Theme: 01 · Foundations · Status: Current (see the 2026-09-03 and 2026-10-01 notes below)

Context

Security decisions were spread across many ADRs — TLS trust (ADR-0201), namespaced isolation and SAR (ADR-0203), redaction (ADR-0303), and HTTP/API auth (ADR-0404) — but there was no single model a reviewer could read to understand Kollect's posture. This ADR records the decision; the maintained security architecture maps it to current controls. (SECURITY.md at the repo root remains the disclosure policy.)

Threat model (what we defend against)

  • A compromised or misconfigured tenant reading/exporting resources outside its namespace.
  • Secrets (registry creds, DB passwords, tokens, kubeconfigs) leaking into exported inventory, logs, or etcd status.
  • Untrusted/MITM'd sink or cluster endpoints.
  • Over-broad operator RBAC enabling privilege escalation.

Decision

Secret handling

  • Credentials are referenced by secretRef, never inlined in CRD specs (ADR-0201).
  • Reconcilers resolve secrets and pass material via BuildContext (SecretData, DatabaseSecretData, CAPEM) — backends never read Kubernetes secrets themselves (ADR-0406).
  • Secret values are not intentionally logged or written to CR status or Events. Known credentials are sanitized in Git errors, but generic third-party backend error strings do not yet have a centralized sanitizer (ADR-0602).

TLS trust

  • Sinks and cluster connections trust a configurable CA (caPEM) resolved from a secret/configmap.
  • insecureSkipVerify exists only where unavoidable (HTTP-ish backends), is off by default, and is an explicit compatibility opt-in. It weakens server identity and is independent of private-address reachability. When set, reconcilers surface status condition TLSInsecure (ConditionTLSInsecure) so operators can see the opt-in without reading the spec. Amended 2026-09-03: that surfacing is conditional on a connection test running and succeeding — see the guard-rail bullet in the corrected section below. Amended 2026-10-01 (K-14): no longer conditional, and the field is no longer settable by any sink author — see insecureSkipVerify requires --allow-insecure-sinks — 2026-10-01 below.
  • Git HTTPS/SSH retains server-name or host-key verification while using the resolved-address guard. Amended 2026-09-03: this is true only while spec.tls.insecureSkipVerify is unset, which is the default. The flag is transport-scoped, not TLS-scoped — see insecureSkipVerify is transport-scoped — corrected 2026-09-03 below.

insecureSkipVerify requires --allow-insecure-sinks — 2026-10-01

K-14 closes the two gaps the 2026-09-03 correction recorded (any sink author could disable verification, and the TLSInsecure condition was unreliable). It supersedes the "Off by default and settable only by whoever can write the sink spec" and "Surfaced in status, but only by a connection test that runs and succeeds" guard rails below; the rest of that section stands.

  • Process-wide opt-in. spec.tls.insecureSkipVerify is denied unless the manager runs with --allow-insecure-sinks (Helm allowInsecureSinks, default false). Like allowPrivateSinks it is a cluster-admin decision, never a CRD field. When enabled the manager logs a startup warning.
  • Enforced twice. Admission rejects the field on create and on update of a family sink (delete is never blocked, and an update that removes the field is accepted). Independently, sinktls.FromSpec, the single TLS resolver for the Git, GitLab, Kafka and NATS sinks, returns validation.ErrInsecureSinksNotAllowed, so the gate also holds for kinds without a webhook and for sinks admitted before the upgrade. This is a breaking change for existing insecure sinks.
  • Always surfaced. setFamilyTLSInsecureCondition now runs on every family-sink reconcile path — probe skipped, probe failed, probe succeeded — and removes the condition when the field is cleared, so TLSInsecure is present exactly when the field is set.
  • Kafka honours spec.tls (K-13) for exports and the connection probe, but turns TLS on only when a CA bundle or insecureSkipVerify is configured; TLS against the system trust store with no bundle is a follow-up.

insecureSkipVerify is transport-scoped — corrected 2026-09-03

The bullet above read as an unconditional claim, and it is not one. spec.tls.insecureSkipVerify is named for TLS but disables verification of the remote's identity for whichever transport the sink's endpoint selects. For an https:// git remote it skips server-certificate verification; for an ssh:// git remote it installs ssh.InsecureIgnoreHostKey on the go-git path (internal/sink/git/ssh_auth.go) and StrictHostKeyChecking=no on the git-CLI path (internal/sink/git/cli_env.go, which the connection test also goes through). This behaviour is pre-existing, not a regression; what was missing was any sentence saying so.

Why the escape hatch is one flag and not two. A sink has exactly one spec.endpoint, and the transport is chosen from that endpoint's scheme (buildAuthMethod in internal/sink/git/auth.go switches on it and rejects a mismatched spec.git.auth.type). HTTPS and SSH are therefore mutually exclusive per sink, and "skip verification of the remote's identity for this sink's transport" is a single coherent contract. Splitting it into a second CRD field would add a public API surface with defaulting and migration cost to express a choice the operator cannot make independently.

Guard rails, with their conditions stated:

  • Off by default and settable only by whoever can write the sink spec.
  • Surfaced in status, but only by a connection test that runs and succeeds. The single setter setFamilyTLSInsecureCondition is called from exactly one place — setConnectionVerified (internal/controller/family_sink_connection.go:109). So on every sink family the TLSInsecure condition appears when spec.tls.insecureSkipVerify is true and a connection test succeeded; spec.connectionTest defaults to true, so that is the usual case. It is absent when spec.connectionTest: false (the reconciler returns before the setter) and while a probe is failing (setConnectionFailed sets ConnectionVerified/Degraded and never touches TLSInsecure, so a previously-set condition also goes stale rather than being cleared). Those are exactly the two states an operator disabling verification is most likely to be in — do not treat the condition's absence as evidence the flag is unset. One exception, in the safe direction: with spec.connectionTest: false plus the kollect.dev/test-connection: "true" annotation the probe still runs once (:96), so the condition is written once and the annotation is then cleared (:123-126); nothing re-runs afterwards, so that True persists even if the flag is later removed. So the absence of the condition proves nothing, and its presence can be stale in either direction. Its message names TLS only.
  • Fails closed on the go-git path: without the flag and without a known_hosts key in the referenced secret, sshAuthMethod returns an error rather than falling back to the system known_hosts or to trust-on-first-use.
  • The git-CLI path has no equivalent fail-closed guard. With the flag unset and no known_hosts supplied it simply omits UserKnownHostsFile and leaves host-key policy to the ambient ssh configuration. It never sets StrictHostKeyChecking=no unless the flag is set, but the outcome on an unknown host is then ssh's default, not a Kollect decision. The export engine is go-git only (ADR-0803); the git-CLI machinery is reached over SSH only by the git ls-remote connection probe, which keeps this ambient-policy behaviour.
  • The resolved-address guard is unaffected by the flag, on both delivery paths: pinGoGitSSHResolution (internal/sink/git/gogit_ssh_guard.go) wraps whatever host-key callback is installed so it is called with the original hostname, and its git-CLI twin guardSSHResolution (internal/sink/git/cli_resolve.go:64) pins -o Hostname=<checked ip> -o HostKeyAlias=<hostname>. Both still dial the NetGuard-checked numeric address and still present the original hostname for verification. When the flag is set there is simply nothing left to verify the hostname against.
  • internal/sink/git/insecure_hostkey_contract_test.go pins all of the above so the code and this ADR cannot drift apart again.

Treat it as a temporary development exception: prefer supplying known_hosts (or a trusted CA for HTTPS), and record the reason and expiry for any sink that sets it.

RBAC (least privilege)

  • The operator's ClusterRole grants only the verbs needed (watch/list/get on selected GVKs; CRUD on Kollect CRDs; leader-election on a single lease).
  • Tenant mode (ADR-0203) narrows watches to configured namespaces; chart emits Role/RoleBinding instead of cluster-wide bindings (ADR-0704).
  • Cross-namespace reads in namespaced inventories are SubjectAccessReview-gated: missing permission degrades gracefully (skip + condition) rather than escalating.

Redaction and data minimization

  • Profiles redact via scrubKeys/redaction before items enter the store (ADR-0303), reducing what can reach the export data contract (ADR-0405).
  • Helm release values, Secret data, and known-sensitive keys are scrubbed at extraction time, not at export time. Key-based scrubbing cannot classify every business-sensitive field.

API / HTTP exposure

  • The read API is feature-gated and off by default. Its default mode uses Kubernetes bearer TokenReview + SubjectAccessReview and is namespace-scoped (ADR-0404); a disabled-auth mode exists for local development.
  • The built-in listener is plain HTTP. Deployers provide transport confidentiality through ingress, a service mesh, a local port-forward, or an equivalent network boundary.

Outbound network policy

  • Sink endpoints are checked at admission when default-on webhooks are enabled and again after DNS resolution at dial time (resolved-address policy).
  • Private RFC1918/ULA sinks are denied by default. The cluster-admin Helm value allowPrivateSinks renders --allow-private-sinks; it is not a CRD or tenant field.
  • Loopback, link-local/cloud metadata, unspecified, multicast, carrier-grade NAT, benchmark ranges, and known metadata hostnames remain denied when the private-sink opt-in is enabled. Default-on admission also rejects file://; disabling webhooks removes that check, and runtime Git accepts valid local bare-repository paths.
  • The chart does not ship a sink-aware egress NetworkPolicy; deployers constrain egress to the Kubernetes API, DNS, and approved sinks.

Multi-cluster

  • Each cluster runs its own manager and exports to shared sinks partitioned by spec.cluster (ADR-0501). There is no privileged central hub transport.

Webhook TLS (serving)

  • Validating webhooks are enabled by default and the chart provisions their serving certificate through cert-manager. Disabling chart-managed certificate resources requires operator-provided serving certificates and CA injection; the chart does not generate TLS material on that path (ADR-0105).

Supply chain

  • Operator, pipeline, and UI images are scanned, signed, SBOM'd, and published with provenance; see ADR-0705.

Consequences

  • A reviewer has one page for the posture; individual ADRs hold the detail.
  • Redaction-at-extraction prevents sinks from exporting fields removed before they enter the store; profile authors must still classify and scrub business-sensitive values.
  • SAR-gated degradation favors availability + safety over hard failure on partial permissions.
  • Private-sink reachability is an explicit cluster-operator trade-off rather than tenant-controlled configuration.

Open questions

  • DECIDED : Encryption-at-rest for sinks (Postgres/object-store) is recommended and documented, not enforced by the operator (it's a backend/infra responsibility).
  • DECIDED : Add a formal RBAC audit gate in CI (kubeaudit-style) as a maturity signal (ADR-0705).
  • OPEN: A built-in secret-leak scanner over outgoing payloads as defense-in-depth beyond scrubKeys?