Security model · 0.x

A team's secrets reach its own reviewed products. Nothing else.

The console checks every rule before anything runs, and admission policies in the cluster check them again on every step's Job. This page is the short version of the security model: what's enforced, by which layer, and what isn't yet.

Read the full security model What isn't enforced yet
At a glance reviewed 2026-11-04
Where do secret values live?
In Kubernetes Secrets in the owning team's namespace, or synced there from the team's own vault. Vennir's databases keep secret names and key names, never values.
Can one team's step reach another team's secret?
No. A step runs in its own team's namespace, and a Pod can only mount Secrets from its own namespace.
Can the console read a secret?
No. It can write and delete Secrets in team namespaces, but its role has no get or list.
Does anything hold if the console is bypassed?
Yes: namespaces, image sources, pins and scans, identities and secrets, signatures. Not if the console itself is compromised; see why.
Where each check happens

Checked at the door, then again by the cluster.

Every run passes four places on its way to a Pod. Each refusal comes back on the step as a code that says why, and the ones in the cluster are non-retryable.

  1. 01 · Console

    Who you are, and what you may run

    • A member or maintainer of every team named, or an admin
    • The step runs as its product's declared ServiceAccount, or default
    • Each secret listed is registered for the step's team
    • The image pinned to its digest, and scanned
    • Literal inputs fit the action's schema; targets allowed for the team; approvals marked
    INPUT_INVALID · TARGET_NOT_ALLOWED · ORDERS_ONLY
  2. 02 · Kernel

    Only the console, and only clean data

    • kernel-api accepts only the console's ServiceAccount token, checked by TokenReview (401, 403)
    • A reference only to a direct dependency
    • Resolved inputs checked before a step starts, outputs when it ends
    • Credential shapes refused in inputs and outputs, redacted from logs
    • A step needing approval held, never claimed, until a maintainer decides
    OUTPUT_INVALID · INPUT_CONTAINS_CREDENTIAL · OUTPUT_CONTAINS_CREDENTIAL
  3. 03 · Cluster admission

    The same rules, on the scheduler's Job

    • Only namespaces labelled vennir/team=true, never the platform's
    • Only allowed image sources, pinned, with a passing scan
    • Only the product's own ServiceAccount, and its team's registered Secrets, as volumes
    • Signed images, with policy-controller installed
    • Only hardened Jobs, while hardening is on
    DISPATCH_REJECTED · IMAGE_NOT_ALLOWED · IMAGE_NOT_PINNED · IMAGE_SCAN_FAILED · IDENTITY_NOT_ALLOWED · IMAGE_NOT_SIGNED
  4. 04 · The step's Pod

    Fenced while it runs

    • Pod Security restricted: non-root, read-only root filesystem, no capabilities
    • No Kubernetes API token
    • Secrets as read-only files, only those it lists
    • Connections in only from its own namespace
    • Over its memory, its time, or its namespace's quota: stopped
    TASK_TIMEOUT · RESOURCES_NOT_ALLOWED
Checked by Vennir's own processes Enforced by Kubernetes itself, so it holds for a Job the console never saw
The boundaries

Six lines no step crosses.

Within a team, secrets are shared on purpose: the team chooses which step gets which. Between teams, and between reviewed and unreviewed code, nothing is.

The second check

Admission policies the API server applies.

ValidatingAdmissionPolicies installed by the charts, plus Sigstore's policy-controller where you want signatures. The console keeps their lists of products, secrets and digests current.

Policy What it refuses Refusal
dispatch-policy.yaml A Job outside a namespace labelled vennir/team=true, or in the platform's own; a Job that isn't hardened while taskPodHardening is on DISPATCH_REJECTED
image-policy.yaml An image outside the source registry set at install and the sources admins add IMAGE_NOT_ALLOWED
scan-policy.yaml An image not pinned by digest, or a digest that failed its scan, unless an admin accepted it IMAGE_NOT_PINNED
IMAGE_SCAN_FAILED
identity-policy.yaml Any ServiceAccount but default unless it's the registered product's own; any Secret not registered for its team; Secrets any way but a volume; a token for an audience the product didn't declare IDENTITY_NOT_ALLOWED
image-signature.yaml An image catalog CI didn't sign (a generated key, a cloud KMS key, or keyless), where policy-controller is installed IMAGE_NOT_SIGNED
purge-policy.yaml A purger deleting a team namespace that isn't labelled retired. None is configured by default, so whoever retires a namespace can't also delete it refused delete
namespace-policy.yaml The console making or adopting a namespace that isn't a team's, changing a team namespace beyond its team label, or touching kube-* or the platform's own refused write
write-fence-policy.yaml The console writing Secrets, ServiceAccounts, RBAC, ExternalSecrets, NetworkPolicies or quotas outside team namespaces, or in any shape but its own refused write
console-jobs-policy.yaml A Job the console makes in the platform's namespace that isn't one of its image scans or catalog verifications as it builds them: another image, identity, Secret or token, root, or anything from the node refused Job

The dispatch, image, scan and identity policies check the scheduler's requests only. Anyone else your cluster lets create Pods in a team namespace, such as a cluster administrator, meets only Pod Security and, where installed, policy-controller.

Network and transport

Teams can't reach each other, or the node's credentials.

All of it needs a network plugin that enforces NetworkPolicy: Calico, Cilium, GKE Dataplane V2, or on EKS the VPC CNI's policy agent, which vennir turns on.

Between team namespaces

Every team namespace holds vennir-default-deny: its Pods accept connections only from their own namespace, and from the API gateway to the backends a team publishes.

Around the platform

Only the platform's own Pods reach its Postgres, and only the console reaches kernel-api. A step reaches neither, nor the scanner, Loki or any metrics port.

The metadata server

On GKE, Workload Identity's: a step gets only its own ServiceAccount's Google identity. On EKS, vennir-no-metadata blocks 169.254.169.254. Elsewhere, it's yours to close.

TLS inside the cluster

On by default: each chart makes its own CA, kernel-api serves only HTTPS, Postgres accepts only TLS, and every client checks the server with verify-full. Metrics, scanner and Loki ports stay plain, fenced by policy.

The console's write fence

The most privileged part, fenced and named as such.

The console writes Secrets, product ServiceAccounts, ExternalSecrets, SecretStores and each team's network policy through one cluster-wide role. The write fence confines that role to team namespaces. The console can't change the fence, checks it's in force before it starts, and refuses to start without it.

Its only RBAC write is one fixed RoleBinding per team namespace, so the scheduler can read its steps' logs there.

If a component were compromised
console
Could rewrite the image, identity and scan policies and run a step with any team's secrets: Kubernetes doesn't apply admission policies to admission policies. Still holding: steps only in team namespaces, no writes outside them, no reading Secrets, policy-controller's signatures.
kernel-api
Its ServiceAccount may only ask whose a token is. A task it wrote still has to pass every Job policy.
scheduler
Creates Jobs and ConfigMaps only in team namespaces, deletes only its own ek- ones, and every Job meets the policies.
gateway
Mints tokens only for *-api ServiceAccounts in team namespaces, reads the database as its own role, and verifies sessions with a public key it can't sign with.
sleeper
Changes replica counts through the scale subresource: it can stop the platform, not change it.
People and pipelines

Sessions that end, bots with no key to leak.

A session only says who you are. Teams, roles and admin are read from the database on every request, so removing someone takes effect at once.

The record

Every change on file, and no credential in the logs.

  • A hash-chained audit log. Every call that can change something, allowed or refused, and every sign-in: who, what it named, the outcome. Never a value, password or token.
  • A write-once copy. With audit.export, entries go to a bucket that refuses deletes and overwrites (GCS retention, S3 Object Lock), compared with the database entry by entry, a few at a time, round and round.
  • Credentials redacted. PEM keys, cloud keys, GitHub, Slack and Stripe tokens, bearer tokens and JWTs are redacted from logs before anything is stored, by the kernel and by the log collector.
  • Signed releases. Each version's kernel, console and gateway images are scanned, then signed keyless by the release workflow, as are the charts and the vennir binaries.
Check a release's image yourself cosign
# Nothing verifies these at install yet; you can, by hand.
$ cosign verify \
    ghcr.io/<owner>/<repo>/execution-kernel:<version> \
    --certificate-identity-regexp \
      'https://github.com/<owner>/<repo>/.github/workflows/release.yml@.*' \
    --certificate-oidc-issuer \
      https://token.actions.githubusercontent.com
No standing credentials

Federation instead of keys, wherever the platform allows it.

An image is the same secret-free bytes for every team. What a step authenticates with is attached when it runs, and it's short-lived wherever the target supports it.

Who Signs in to With
A step Its cloud Its product's own ServiceAccount: GKE Workload Identity, EKS Pod Identity, or an hour's token per declared audience for another cloud
Catalog CI The registry, and the signing key GitHub's OIDC token, trusted only for the repository's main; no console or cluster credential at all
A team's vault GCP Secret Manager, AWS Secrets Manager External Secrets Operator as the team's own workload identity; one cloud identity per team's store
The platform A managed database IAM database authentication: each service as its own identity, a short-lived token, no password of Vennir's
Another install Your catalog's registry vennir catalog grant, naming exactly its identity; never a key
The installer Your cloud and git host Your own sign-ins; vennir holds no credentials, and its config file holds only names
What isn't enforced yet

Vennir is 0.x, and not production-ready. These gaps are why.

Each one is tracked in TODO.md and described in the security model.

A compromised console can loosen three policies

Kubernetes can't guard an admission policy from its editor, so the console could allow any image, ServiceAccount or Secret. Accepted for now: treat the console as the most privileged component.

Token groups are read at sign-in only

Someone who leaves a group the provider reports in its token stays in its team until the session ends. Google Workspace groups are read again every hour. Shorten JWT_TTL where that matters.

Reads aren't audited

Who looked at a run's outputs or logs, or a team's secrets' names, isn't recorded. Not decided yet.

Without the copy, the audit log can be rewritten

It lives in the console's database. Someone who can write there could rewrite the chain after a change. Turn on audit.export, or ship the audit log lines to a SIEM.

Step hardening is a chart setting

With taskPodHardening: false, only Pod Security holds a step, and only in namespaces the console created; in an adopted one it runs as root with an API token.

A raw DAG shows every step's outputs

Every team named in a raw DAG sees all its steps' outputs. A template run's providers see only their own steps in full. To decide: filter by team, or require every team for reads.

Some limits that are yours today

No second factor for password accounts (use single sign-on). Outgoing traffic from steps isn't restricted. Encrypting etcd, Postgres disks and backups is the operator's. Nothing verifies Vennir's own signatures at install.

Not yet proven

Syncing from Azure Key Vault, HashiCorp Vault and 1Password: the console recognises them, but only GCP Secret Manager and AWS Secrets Manager are proven end to end. Azure isn't an install target yet.

Out of scope

A malicious product that a reviewer approved; a compromised cluster administrator, node or control plane; side channels between Pods on one node; the security of a team's own vault and the identities it trusts.

Found a vulnerability? Tell us privately.

Use the repository's Security tab, Report a vulnerability, never a public issue. You'll hear back within 5 working days, and a fix is released before details are published, within 90 days unless we agree otherwise. Only the latest 0.x release gets security fixes.

Report a vulnerability Read SECURITY.md