The Website Does Not Hold the Credential

The contact form on this site used to save every message to a file and stop there. Nobody got told. Finding out someone had reached out meant remembering to go open that file, which in practice meant it mostly didn't happen until much later than it should have.

The obvious fix looked like an afternoon of work: give the website its own devchitchat credential, open a connection when a message comes in, post it to the channel. That's the version I actually started building first. Then I stopped, because of what that credential would be sitting next to.

Why the obvious fix was wrong

The contact form takes input from anyone on the public internet, with no login and no identity check of any kind. That's the entire point of a contact form. It's also exactly the shape of surface you don't want holding a credential that can post as a trusted bot into an internal chat system — the same reasoning that's shown up in every version of this pattern on this blog: the thing accepting arbitrary input from strangers should never be the thing holding the keys to something else.

A bug in the form handler, a dependency with a bad update, a request shaped in a way nobody tested — any of those, and whatever that process can do, an attacker can do. If the answer to "what can it do" includes "post as the bot anywhere it's a member," that's a much bigger blast radius than a contact form has any business creating.

What we built instead

The website and the notification are now two separate pieces that never share a credential.

When someone submits the form, the website doesn't talk to devchitchat at all. It creates a Kubernetes custom resource — a ContactFormSubmission, in a namespaced API group we defined for this — using its own scoped ServiceAccount. That ServiceAccount has exactly one permission: create on that one resource type. It can't read a channel, can't send a message, can't do anything else in the cluster.

A separate process — the chatops bot, which already holds the one devchitchat credential that matters — polls for new ContactFormSubmission resources, formats a message, and posts it. After a successful post, it patches the resource's status subresource with delivered: true, so a restart doesn't cause the same submission to get announced twice.

The website creates a ContactFormSubmission custom resource. A separate watcher process reads it and posts to chat. The two never share a credential. website holds no credential create ContactFormSubmission custom resource, spec + status poll chatops bot holds the credential post chat the website and the bot never share a credential
the website's ServiceAccount can create one resource type and nothing else. it never opens a connection to the chat server.

Why a custom resource, not a generic Event

The first version of this used Kubernetes' built-in Event object, which seemed like the obvious fit — it's already there, no setup required. It's also the wrong tool for this, for two concrete reasons.

Events expire. Most clusters garbage-collect them after about an hour by default. A poll-based watcher that's briefly down, or a poll interval that's a little too slow, means a real submission can simply disappear before anything reads it. A custom resource sticks around until something explicitly deletes it — nothing is racing a TTL to deliver a notification.

Events also have a generic schema that isn't shaped for this. A ContactFormSubmission resource has a spec with exactly the fields this needs — submitter name, email, message — instead of one loosely-typed message string. And it has a real status subresource, which is the idiomatic place to record whether a submission has been delivered yet. Trying to bolt that same tracking onto a generic Event would have meant inventing a workaround Kubernetes already gives you for free if you define your own resource type.

The actual point

None of this required building new infrastructure. A ServiceAccount, a Role scoped to one resource type, and a custom resource definition are all things Kubernetes already provides — the same instinct behind not reaching for a new protocol when something already does the job. The credential boundary here isn't a service we stood up. It's a permission we didn't grant.