weebo-forgejo

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

WhoCanBounded by
Cluster adminsCreate the cluster-scoped kinds: instances, namespace policiesKubernetes RBAC
Application teamsCreate namespaced kinds in their namespaces: local instances, users, user keys, organizations, teams, repositories, webhooks, deploy keys, OAuth2 applications, access tokensRBAC, and the allow-list for what is bound to a shared ForgejoInstance
The operatorAct as a Forgejo site admin; read referenced Secrets, write the credentials it generatesIts 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

ResourceVerbsWhy
The forgejo.weebo.io kinds (+ /status, /finalizers)get, list, watch, update, patchIts own API, cluster-wide: namespaced kinds live in any namespace
secretsget, create, patch, deleteSee below. Never list or watch
events.k8s.io eventscreate, patchAn Event on every Ready transition
namespaces (cluster-wide releases only)getThe labels of a referencing namespace, when the target of a cross-namespace reference shares itself through a namespaceSelector
coordination.k8s.io leases, release namespace onlyget, create, update, patchLeader 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 secretTargets into its own namespace, and only reference Secrets (a webhook's secretRef) in its own namespace. A target naming another namespace is refused before anything is written — by the admission webhook and again by the reconciler — with CrossNamespaceReference. A Vault path must stay under <pathPrefix>/<namespace>/ (on a cluster-scoped CR, the target's namespace), unless the instance's pathGrants open 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-forgejo and annotated forgejo.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 (ForgejoUser and ForgejoUserKey included, 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 ForgejoUser with admin: true (managementPolicy: Manage) makes its account a site admin: it needs the same explicit rule, for the ForgejoUser kind;
  • a user referenced by remoteName, or through a ForgejoUser in managementPolicy: 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 Secret targets keep the dead values until the CR is allowed again and issues new ones; the CR reports NamespaceNotAllowed and no longer records a Forgejo id. An application without ownerRef (@instance-admin) is never deleted this way: the admin account is checked on the CR. A ForgejoDeployKey is 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 admin

A 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 failurePolicy is Fail: while no replica answers, namespaced CRs can't be created or updated. Don't switch it to Ignore; 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 / objectSelector can 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:

DestinationValueDefault
Cluster DNSegress.dnskube-dns in kube-system, port 53 UDP/TCP
The API serveregress.apiServer.torequired: the kubernetes endpoints' addresses
Every Forgejo a ForgejoInstance points ategress.forgejonone
Vault, if a secret store uses itegress.vaultnone
Anything else (OTLP collector, …)egress.extranone
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.

Next

Troubleshooting.

On this page