Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ build: manifests generate fmt vet ## Build manager binary.

.PHONY: run
run: manifests generate fmt vet ## Run a controller from your host.
go run ./cmd/manager
POD_NAMESPACE=$${POD_NAMESPACE:-github-token-manager} go run ./cmd/manager

# If you wish to build the manager image targeting other platforms you can use the --platform flag.
# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.
Expand Down
105 changes: 103 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This operator solves the problem by functioning like cert-manager for GitHub tok
- **🔐 Zero-Trust Security**: Never store GitHub App private keys in-cluster - integrates with AWS KMS, Azure Key Vault, Google Cloud KMS, and HashiCorp Vault
- **⏰ Ephemeral & Auto-Rotating**: Tokens expire in 1 hour and refresh automatically before expiration
- **🎯 Fine-Grained Permissions**: Each token can have different scopes, down to specific repositories and permissions
- **🏢 Multi-Tenancy**: Namespace isolation with `Token` CRD, cluster-wide control with `ClusterToken`
- **🏢 Multi-Tenancy**: Namespace isolation with `Token` CRD, cluster-wide control with `ClusterToken`, and optional per-tenant `App` credentials
- **🚀 GitOps-Ready**: Native FluxCD integration with HTTP Basic Auth secret generation
- **📊 Production-Ready**: Prometheus metrics, health probes, intelligent retry logic with exponential backoff

Expand Down Expand Up @@ -136,7 +136,9 @@ kind: ClusterToken # or Token
metadata:
name: foo
spec:
installationID: 321 # (optional) override GitHub App Installation ID configured for the operator
appRef: # (optional) reference an App CR for per-tenant credentials; see "Multiple GitHub Apps"
name: prod-app
installationID: 321 # (optional) override GitHub App Installation ID configured for the operator or App
permissions: {} # (optional) map of token permissions, default: all permissions assigned to the GitHub App
refreshInterval: 45m # (optional) token refresh interval, default 30m
retryInterval: 1m # (optional) token retry interval on ephemeral failure; default: 5m
Expand All @@ -150,6 +152,100 @@ spec:
namespace: default # (required, ClusterToken-only) set the target namespace for managed `Secret`
```

### Multiple GitHub Apps (`App` CRD)

Deployments that need multiple GitHub App configurations — different orgs, per-tenant Apps, or installations with different key providers — can declare `App` resources as the sole credential source, alongside, or instead of the startup `Secret/gtm-config`. `Token.spec.appRef` and `ClusterToken.spec.appRef` then select which App to use; when `appRef` is omitted, the startup config remains the fallback so **existing deployments need no changes**.

The startup `Secret/gtm-config` is optional: the operator's `/config` volume is mounted with `optional: true`, so an App-CR-only install does not require the Secret to exist. The Helm chart only renders the Secret when `config.app_id` is non-zero, and the Secret name is overridable via `config.secretName` (default `gtm-config`) for users who manage it externally (e.g. ESO, Sealed Secrets).

**Cloud KMS-backed App:**

```yaml
apiVersion: github.as-code.io/v1
kind: App
metadata:
name: prod-app
namespace: team-platform
spec:
appID: 12345
installationID: 67890
provider: aws # aws | azure | gcp | vault
key: alias/prod-gh-app # provider-specific key reference
validateKey: true # optional: test-sign the key at reconcile time
```

**Secret-backed App** (PEM-encoded RSA private key in a same-namespace Secret):

```yaml
apiVersion: v1
kind: Secret
metadata:
name: prod-app-key
namespace: team-platform
type: Opaque
stringData:
private-key.pem: |
-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----
---
apiVersion: github.as-code.io/v1
kind: App
metadata:
name: prod-app
namespace: team-platform
spec:
appID: 12345
installationID: 67890
provider: secret
keyRef:
name: prod-app-key
# key: private-key.pem # default; matches GitHub's downloaded filename
validateKey: true
```

The spec fields mirror the startup configuration with one deliberate divergence: `provider: file` is **not** accepted on an `App`. Because an `App` is namespaced, allowing a filesystem path would let any namespace owner reference key material mounted on the controller Pod for unrelated tenants. Inline keys go through `provider: secret` + a same-namespace Secret instead; tenant isolation is then enforced by Kubernetes RBAC on Secrets in that namespace, and the Secret can be managed by ESO, Sealed Secrets, Vault CSI, or `kubectl create secret`. The `App` reconciler watches its keyRef Secret and rebuilds the signer client on rotation. It surfaces a `Ready` condition on the resource; when `validateKey: true`, it also surfaces a `KeyValid` condition.

**Token references (same-namespace only):**

```yaml
apiVersion: github.as-code.io/v1
kind: Token
metadata:
name: ci-token
namespace: team-platform
spec:
appRef:
name: prod-app # must live in the Token's own namespace
```

**ClusterToken references (cross-namespace):**

```yaml
apiVersion: github.as-code.io/v1
kind: ClusterToken
metadata:
name: shared-token
spec:
appRef:
name: prod-app
namespace: team-platform # optional; defaults to the operator's own namespace
secret:
namespace: flux-system
```

When a referenced `App` is missing or not yet `Ready`, the Token surfaces a `Ready=False` condition with reason `AppNotFound`, `AppNotReady`, or `SetupFailed`. The controller watches `App` resources so Tokens automatically re-reconcile once the App becomes ready or its spec is corrected.

**Migration note:** No changes are required when upgrading — existing Tokens and ClusterTokens without `spec.appRef` continue to use the startup `Secret/gtm-config`. Adopting the `App` CRD per workload is entirely opt-in.

#### Security model: ClusterToken and App references

`ClusterToken` is cluster-scoped and `spec.appRef.namespace` accepts any namespace. The operator runs with cluster-wide read on `App` resources, so there is no Kubernetes RBAC barrier between a `ClusterToken` creator and the `App`s they may reference.

**Granting `create` or `update` on `ClusterToken` is therefore equivalent to granting use of every `App` in every namespace** — including any `App` in the operator's own namespace.

In multi-tenant clusters, restrict `ClusterToken` write permissions to cluster administrators, or enforce a `spec.appRef.namespace` allow-list with an admission policy (Kyverno, OPA Gatekeeper, or `ValidatingAdmissionPolicy`). The namespaced `Token` does not have this concern: it can only reference `App`s in its own namespace.

### Examples

**FluxCD Git Repository Access:**
Expand Down Expand Up @@ -202,6 +298,9 @@ Available opt-out tags: `ghait.no_aws`, `ghait.no_azure`, `ghait.no_gcp`, `ghait
# Build and test
make build test lint

# Run the controller from your host (defaults POD_NAMESPACE=github-token-manager)
make run

# Deploy locally
make ko-build IMG=<registry>/github-token-manager:tag
make deploy IMG=<registry>/github-token-manager:tag
Expand All @@ -210,6 +309,8 @@ make deploy IMG=<registry>/github-token-manager:tag
make undeploy uninstall
```

The manager requires `POD_NAMESPACE` to know its own namespace (in-cluster, the chart injects it via the downward API). `make run` defaults it to `github-token-manager`, matching the kustomize deploy namespace at `config/default/kustomization.yaml`; override by exporting `POD_NAMESPACE` before invoking. If you run `go run ./cmd/manager` directly, set it yourself.

Run `make help` for all available targets. See [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) for details.

## Contributing
Expand Down
149 changes: 149 additions & 0 deletions api/v1/app_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
Copyright 2024 Robin Breathe.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1

import (
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// AppSpec defines the desired state of an App.
//
// Provider selects how the App's RSA private key is materialised. For cloud
// KMS (aws/azure/gcp/vault) the key reference is supplied inline via Key.
// For "secret" the key bytes live in a same-namespace Secret named by
// KeyRef; this is the only supported way to use an inline PEM with an App,
// because allowing arbitrary filesystem paths from a namespaced resource
// would let any namespace owner read key material mounted on the controller
// Pod.
//
// +kubebuilder:validation:XValidation:rule="(self.provider == 'secret') == has(self.keyRef)",message="keyRef must be set if and only if provider is 'secret'"
// +kubebuilder:validation:XValidation:rule="(self.provider != 'secret') == has(self.key)",message="key must be set if and only if provider is not 'secret'"
type AppSpec struct {
// Important: Run "make" to regenerate code after modifying this file

// +kubebuilder:validation:Required
// +kubebuilder:validation:Minimum=1
// +kubebuilder:example:=12345
// The AppID of the GitHub App.
AppID int64 `json:"appID"`

// +kubebuilder:validation:Required
// +kubebuilder:validation:Minimum=1
// +kubebuilder:example:=123456789
// The default InstallationID of the GitHub App; Tokens/ClusterTokens may
// override this via spec.installationID to target a different installation.
InstallationID int64 `json:"installationID"`

// +kubebuilder:validation:Required
// +kubebuilder:validation:Enum=secret;aws;azure;gcp;vault
// Private key provider. One of "secret" (PEM material in a same-namespace
// Secret, referenced by keyRef), "aws" (AWS KMS), "azure" (Azure Key
// Vault), "gcp" (Google Cloud KMS), or "vault" (HashiCorp Vault transit).
// The "file" provider is intentionally not supported on an App; use the
// operator's startup configuration for file-based keys.
Provider string `json:"provider"`

// +optional
// Cloud-KMS key reference. Required when provider is "aws", "azure",
// "gcp", or "vault"; forbidden when provider is "secret". The exact
// shape depends on the provider: KMS key alias/ID/ARN (aws), Azure Key
// Vault key URL (azure), GCP KMS resource name (gcp), or Vault transit
// sign path (vault).
Key string `json:"key,omitempty"`

// +optional
// Same-namespace Secret reference holding the PEM-encoded RSA private
// key. Required when provider is "secret"; forbidden otherwise.
KeyRef *KeySecretReference `json:"keyRef,omitempty"`

// +optional
// +kubebuilder:default:=false
// If true, the operator validates the private key at reconcile time by
// attempting a test sign. Failures surface as a KeyValid=False condition.
ValidateKey bool `json:"validateKey,omitempty"`
}

// KeySecretReference identifies a same-namespace Secret holding a
// PEM-encoded RSA private key for a GitHub App.
type KeySecretReference struct {
// +kubebuilder:validation:Required
// +kubebuilder:validation:MaxLength:=253
// Name of the Secret in the App's namespace.
Name string `json:"name"`

// +optional
// +kubebuilder:default:="private-key.pem"
// Key within the Secret's data map containing the PEM-encoded RSA
// private key. Defaults to "private-key.pem", which matches the
// filename GitHub uses when downloading App keys.
Key string `json:"key,omitempty"`
}

// AppStatus defines the observed state of an App.
type AppStatus struct {
// Important: Run "make" to regenerate code after modifying this file

// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`

// +optional
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced,shortName=app,path=apps
// +kubebuilder:printcolumn:name="App ID",type=integer,JSONPath=`.spec.appID`
// +kubebuilder:printcolumn:name="Installation ID",type=integer,JSONPath=`.spec.installationID`
// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.provider`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

// App is the Schema for the apps API; it encapsulates a GitHub App
// configuration that Tokens and ClusterTokens may reference via spec.appRef.
type App struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec AppSpec `json:"spec,omitempty"`
Status AppStatus `json:"status,omitempty"`
}

// GetStatusConditions returns the App's status conditions slice.
func (a *App) GetStatusConditions() []metav1.Condition {
return a.Status.Conditions
}

// SetStatusCondition updates the App's status conditions in place,
// returning true when the resulting slice differs from the prior value.
func (a *App) SetStatusCondition(condition metav1.Condition) (changed bool) {
return meta.SetStatusCondition(&a.Status.Conditions, condition)
}

// +kubebuilder:object:root=true

// AppList contains a list of App.
type AppList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata"`
Items []App `json:"items"`
}

func init() {
SchemeBuilder.Register(&App{}, &AppList{})
}
Loading
Loading