Security model
What the operator may do in the cluster and in Forgejo, the rules that bound it, and how to fence it with RBAC and a NetworkPolicy.
The operator holds a Forgejo site-administrator token and can write Secrets anywhere in the cluster. This page explains why, and what stops a namespace from turning that power against another one.
Trust boundaries
| Who | Can | Bounded by |
|---|---|---|
| Cluster admins | Create the cluster-scoped kinds: instances, namespace policies | Kubernetes RBAC |
| Application teams | Create namespaced kinds in their namespaces: local instances, users, user keys, organizations, teams, repositories, webhooks, deploy keys, OAuth2 applications, access tokens | RBAC, and the allow-list for what is bound to a shared ForgejoInstance |
| The operator | Act as a Forgejo site admin; read referenced Secrets, write the credentials it generates | Its ClusterRole, and the rules below |
Grant the cluster-scoped kinds to cluster admins only. On a shared
ForgejoInstance, govern who may create users, organizations and teams with
the allow-list (allowedKinds, allowedUsers, allowedOrganizations): a
ForgejoUser with admin: true is a Forgejo site admin. The chart's
rbac.namespacedExample renders a Role with exactly the namespaced kinds for
a team.
References across namespaces
A CR references users, organizations and teams of its own namespace by
default. A reference to another namespace (namespace on the ref) needs
both the release (references.crossNamespace: true) and the target
(spec.sharedWith listing the namespace, or selecting it); otherwise the CR
is Ready: False with ReferenceNotAllowed. A remoteName references a
Forgejo object no CR manages, by name: the operator only checks it exists,
and never changes it — the allow-list matches it by that Forgejo name too.
One owner per Forgejo object
A user, organization or team on a Forgejo server is owned by one CR: a
second CR — in any namespace, through any instance pointing at the same
server — that would create or adopt it is refused with
RemoteObjectClaimed, before any call to Forgejo. Reference it with
remoteName instead.
The operator's RBAC
| Resource | Verbs | Why |
|---|---|---|
The forgejo.weebo.io kinds (+ /status, /finalizers) | get, list, watch, update, patch | Its own API, cluster-wide: namespaced kinds live in any namespace |
secrets | get, create, patch, delete | See below. Never list or watch |
events.k8s.io events | create, patch | An Event on every Ready transition |
namespaces (cluster-wide releases only) | get | The labels of a referencing namespace, when the target of a cross-namespace reference shares itself through a namespaceSelector |
coordination.k8s.io leases, release namespace only | get, create, update, patch | Leader election (a Role, not a ClusterRole) |
Why cluster-wide Secrets
The operator reads the Secrets a CR references (the instance token, a webhook HMAC secret, migration credentials, a Vault CA) and writes the credentials it generates (passwords, SSH keys, OAuth2 client secrets, access tokens) into the namespaces the CRs name. Both sides can be any namespace, so a namespaced Role per namespace would mean redeploying RBAC for every new team. Instead, Secrets are only ever addressed by name, never listed or watched, so the operator can't enumerate them, and the rules below bound where it writes.
Where a CR may write Secrets
- A namespaced CR (an access token, an OAuth2 application, a deploy key)
may only write
secretTargetsinto its own namespace, and only reference Secrets (a webhook'ssecretRef) in its own namespace. A target naming another namespace is refused before anything is written — by the admission webhook and again by the reconciler — withCrossNamespaceReference. A Vaultpathmust stay under<pathPrefix>/<namespace>/(on a cluster-scoped CR, the target's namespace), unless the instance'spathGrantsopen it to the CR's namespace. Vault values record their owner in the KV custom metadata (owner) and are never overwritten nor deleted by another CR. - The operator only overwrites or deletes a Secret it owns: one labelled
app.kubernetes.io/managed-by: weebo-forgejoand annotatedforgejo.weebo.io/owner: <Kind>/<namespace>/<name>naming the CR (a CR re-created under the same name takes its Secrets back). Secrets emitted by 0.1.0 carry the label only: the first CR writing one adopts it. A target naming an existing Secret it doesn't own (your database password, another CR's token) fails (SecretTargetFailed) and the Secret is left untouched. - Every CR writes into its own namespace only (
ForgejoUserandForgejoUserKeyincluded, since they are namespaced); the same ownership rule applies.
So a team can neither exfiltrate a credential into a namespace it doesn't own, nor clobber a Secret it didn't create through the operator.
Tokens and OAuth2 applications for administrators
An access token, or an OAuth2 application owned by a user, acts as that
user. For a ForgejoUser with admin: true that is full control of
Forgejo. So a ForgejoAccessToken or ForgejoOAuth2Application referencing
an admin user is refused unless a ForgejoNamespacePolicy rule explicitly
lists that user in allowedUsers for the CR's namespace and kind, and
this holds even when no policy exists at all (normally "no policy" means
"allow everything"). An unset allowedUsers ("any user") doesn't count.
The same goes for an OAuth2 application without ownerRef: it belongs
to the operator's own admin account, so it needs a rule listing
@instance-admin.
What counts as a site admin isn't only what the CR says:
- a
ForgejoUserKey(an SSH key pushes as its user) follows the same rule; - a
ForgejoUserwithadmin: true(managementPolicy: Manage) makes its account a site admin: it needs the same explicit rule, for theForgejoUserkind; - a user referenced by
remoteName, or through aForgejoUserinmanagementPolicy: Observe, is treated as a site admin by the webhook: its flags aren't the CR's to say; - before minting, each reconciler asks Forgejo whether the account is a
site admin, whatever its CR declares. An account made a site admin
outside the operator, or after its credentials were issued, is refused
the same way, and what was already issued for it is revoked: its
access token, its OAuth2 application (deleted, its client secret with
it) and its SSH key (deleted from the account). The
Secrettargets keep the dead values until the CR is allowed again and issues new ones; the CR reportsNamespaceNotAllowedand no longer records a Forgejo id. An application withoutownerRef(@instance-admin) is never deleted this way: the admin account is checked on the CR. AForgejoDeployKeyis not concerned: it pushes to one repository, not as an account.
apiVersion: forgejo.weebo.io/v1alpha1
kind: ForgejoNamespacePolicy
metadata:
name: platform-admin-token
spec:
rules:
- namespaces: [platform-ci]
allowedKinds: [ForgejoAccessToken]
allowedUsers: [admin-bot] # explicit: admin-bot is a site adminA CR never changes instance
The instance a CR was first resolved to is recorded in status.instance,
and the operator never acts on another one for that CR: re-pointing
spec.instanceRef leaves it Ready: False (InstanceBindingChanged)
until instanceRef designates it again. Moving a CR to another Forgejo
would otherwise leave its object behind in the first one, and let a CR
re-point at an instance its namespace wasn't meant to reach. To move a CR,
delete it (with deletionPolicy: Orphan if the object must stay) and create
it again. A ForgejoLocalInstance of another namespace is only usable when
it lists the CR's namespace in its sharedWith.
A local instance only uses a Secret labelled for it
The operator reads Secrets with its own rights. A ForgejoLocalInstance
picks its URL and its token Secret, so the operator only reads a token
Secret labelled forgejo.weebo.io/instance-token: "true": being allowed
to create a local instance doesn't let anyone send another Secret of the
namespace to a server of their choosing (TokenSecretNotAllowed).
The same goes for the Secrets a CR hands to Forgejo, which sends them to a
URL the CR names (a migration's cloneAddr, a webhook's url):
migrate.authSecretRef, secretRef and authorizationHeaderSecretRef are
only read from a Secret labelled forgejo.weebo.io/credential: "true"
(SecretNotAllowed).
Admission webhook
- The
failurePolicyisFail: while no replica answers, namespaced CRs can't be created or updated. Don't switch it toIgnore; the reconcilers would still refuse disallowed CRs, but only after they're stored. See Troubleshooting for what to do when it's down. - It never sees deletes, so revoking access never blocks cleanup.
webhook.namespaceSelector/objectSelectorcan exclude objects (e.g. system namespaces). Excluded CRs are still checked by the reconcilers.
Network policy
networkPolicy.enabled=true renders a NetworkPolicy for the operator pods.
Ingress is limited to the webhook port (8443) and, when metrics are on,
the metrics port (9090). The API server's source address depends on the
cluster (host network, managed control plane), so webhookFrom defaults to
any source; restrict it when you know it. Restrict metricsFrom to your
Prometheus namespace.
Egress is only restricted with networkPolicy.egress.enabled=true. The
operator then needs, and you must list:
| Destination | Value | Default |
|---|---|---|
| Cluster DNS | egress.dns | kube-dns in kube-system, port 53 UDP/TCP |
| The API server | egress.apiServer.to | required: the kubernetes endpoints' addresses |
Every Forgejo a ForgejoInstance points at | egress.forgejo | none |
| Vault, if a secret store uses it | egress.vault | none |
| Anything else (OTLP collector, …) | egress.extra | none |
networkPolicy:
enabled: true
metricsFrom:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
egress:
enabled: true
apiServer:
# kubectl get endpoints kubernetes -o jsonpath='{.subsets[*].addresses[*].ip}'
to:
- ipBlock: { cidr: 10.0.0.10/32 }
forgejo:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: forgejo }
ports:
- { port: 3000, protocol: TCP }
vault:
- to:
- ipBlock: { cidr: 192.0.2.20/32 }
ports:
- { port: 8200, protocol: TCP }A blocked Forgejo shows up as InstanceUnreachable on the
ForgejoInstance; a blocked API server makes the pods fail their probes.
Supply chain
Release images and charts are built in GitHub Actions, carry an SBOM and SLSA
provenance, and are signed keylessly with cosign. See
Verify a release. Vulnerabilities are reported
privately, see the repository's SECURITY.md.