Repository: akuity/kargo
Pull request #1375 (2024-01-06 02:20:35)

refactor(controller): minor config issues with credentials db
While investigating #1373, although I have not yet been able to replicate it, I noticed a few things related to how we specify and load credentials database config that could stand to be cleaned up a bit:

* The chart has a superfluous configuration option. Removing it promotes consistency with how a similar feature ("global" service accounts) is configured.

* Some inaccurate comments about indexing performed by the credentials database component. That implementation detail was actually removed quite some time ago.

* The method of loading credential db configuration was a bit complex and not consistent with how we load configuration for other components.

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *docs-kargo-akuity-io* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 829f81aca1e3a87545f08564a79bfe42ea6aa879 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-akuity-io/deploys/6598b8f699d92000081d067a |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/1375?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) Report
Attention: `7 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`531a42d`)](https://app.codecov.io/gh/akuity/kargo/commit/531a42dc46526af209c31ad0d1f8ba1f1efb31f1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) 45.59% compared to head [(`829f81a`)](https://app.codecov.io/gh/akuity/kargo/pull/1375?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) 45.50%.
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/akuity/kargo/pull/1375?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) | Patch % | Lines |
|---|---|---|
| [internal/credentials/credentials.go](https://app.codecov.io/gh/akuity/kargo/pull/1375?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvY3JlZGVudGlhbHMvY3JlZGVudGlhbHMuZ28=) | 30.00% | [7 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1375?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1375      +/-   ##
==========================================
- Coverage   45.59%   45.50%   -0.09%     
==========================================
  Files         135      134       -1     
  Lines       11664    11645      -19     
==========================================
- Hits         5318     5299      -19     
  Misses       6155     6155              
  Partials      191      191              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/akuity/kargo/pull/1375?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).


Patch:
diff --git a/Tiltfile b/Tiltfile
index 0e1caaec..a32c4714 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -120,6 +120,19 @@ k8s_resource(
   resource_deps=['back-end-compile']
 )
 
+k8s_resource(
+  workload = 'kargo-management-controller',
+  new_name = 'management-controller',
+  labels = ['kargo'],
+  objects = [
+    'kargo-management-controller:clusterrole',
+    'kargo-management-controller:clusterrolebinding',
+    'kargo-management-controller:configmap',
+    'kargo-management-controller:serviceaccount'
+  ],
+  resource_deps=['back-end-compile']
+)
+
 k8s_resource(
   workload = 'kargo-ui',
   new_name = 'ui',
@@ -152,9 +165,10 @@ k8s_resource(
   new_name = 'crds',
   objects = [
     'freights.kargo.akuity.io:customresourcedefinition',
-    'stages.kargo.akuity.io:customresourcedefinition',
+    'projects.kargo.akuity.io:customresourcedefinition',
     'promotionpolicies.kargo.akuity.io:customresourcedefinition',
     'promotions.kargo.akuity.io:customresourcedefinition',
+    'stages.kargo.akuity.io:customresourcedefinition',
     'warehouses.kargo.akuity.io:customresourcedefinition'
   ],
   labels = ['kargo']
diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go
index 1497c2fb..e7d2985d 100644
--- a/api/v1alpha1/groupversion_info.go
+++ b/api/v1alpha1/groupversion_info.go
@@ -31,6 +31,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
 		&FreightList{},
 		&Stage{},
 		&StageList{},
+		&Project{},
+		&ProjectList{},
 		&Promotion{},
 		&PromotionList{},
 		&PromotionPolicy{},
diff --git a/api/v1alpha1/project_types.go b/api/v1alpha1/project_types.go
new file mode 100644
index 00000000..0b8d0cd8
--- /dev/null
+++ b/api/v1alpha1/project_types.go
@@ -0,0 +1,46 @@
+package v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+//+kubebuilder:object:root=true
+//+kubebuilder:resource:scope=Cluster
+//+kubebuilder:subresource:status
+//+kubebuilder:printcolumn:name=Age,type=date,JSONPath=`.metadata.creationTimestamp`
+
+// Project is a resource type that reconciles to a specially labeled namespace
+// and other TODO: TBD project-level resources.
+type Project struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+	// Spec describes a Project.
+	//
+	//+kubebuilder:validation:Required
+	Spec *ProjectSpec `json:"spec"`
+	// Status describes the Project's current status.
+	Status ProjectStatus `json:"status,omitempty"`
+}
+
+func (p *Project) GetStatus() *ProjectStatus {
+	return &p.Status
+}
+
+// ProjectSpec describes a Project.
+type ProjectSpec struct {
+	// TODO: Figure out the attributes of a ProjectSpec.
+}
+
+// ProjectStatus describes a Project's current status.
+type ProjectStatus struct {
+	// TODO: Figure out the attributes of a ProjectStatus.
+}
+
+//+kubebuilder:object:root=true
+
+// ProjectList is a list of Project resources.
+type ProjectList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Project `json:"items"`
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 945c489a..65cb39cb 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -721,6 +721,99 @@ func (in *KustomizePromotionMechanism) DeepCopy() *KustomizePromotionMechanism {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Project) DeepCopyInto(out *Project) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	if in.Spec != nil {
+		in, out := &in.Spec, &out.Spec
+		*out = new(ProjectSpec)
+		**out = **in
+	}
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Project.
+func (in *Project) DeepCopy() *Project {
+	if in == nil {
+		return nil
+	}
+	out := new(Project)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Project) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProjectList) DeepCopyInto(out *ProjectList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Project, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectList.
+func (in *ProjectList) DeepCopy() *ProjectList {
+	if in == nil {
+		return nil
+	}
+	out := new(ProjectList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ProjectList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProjectSpec) DeepCopyInto(out *ProjectSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectSpec.
+func (in *ProjectSpec) DeepCopy() *ProjectSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ProjectSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProjectStatus) DeepCopyInto(out *ProjectStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectStatus.
+func (in *ProjectStatus) DeepCopy() *ProjectStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(ProjectStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Promotion) DeepCopyInto(out *Promotion) {
 	*out = *in
diff --git a/charts/kargo/README.md b/charts/kargo/README.md
index 220778e0..2d5e101c 100644
--- a/charts/kargo/README.md
+++ b/charts/kargo/README.md
@@ -95,7 +95,6 @@ the Kargo controller is running.
 | Name                                          | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Value       |
 | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
 | `controller.enabled`                          | Whether the controller is enabled.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | `true`      |
-| `controller.globalCredentials.enabled`        | Whether to enable the controller to look for credentials stored in specific namespaces used to share credentials.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | `false`     |
 | `controller.globalCredentials.namespaces`     | List of namespaces to look for shared credentials.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | `[]`        |
 | `controller.shardName`                        | Set a shard name only if you are running multiple controllers backed by a single underlying control plane. Setting a shard name will cause this controller to operate **only** on resources with a matching shard name. Leaving the shard name undefined will designate this controller as the default controller that is responsible exclusively for resources that are **not** assigned to a specific shard. Leaving this undefined is the correct choice when you are not using sharding at all. It is also the correct setting if you are using sharding and want to designate a controller as the default for handling resources not assigned to a specific shard. In most cases, this setting should simply be left alone. | `undefined` |
 | `controller.argocd.namespace`                 | The namespace into which Argo CD is installed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `argocd`    |
@@ -107,6 +106,17 @@ the Kargo controller is running.
 | `controller.tolerations`                      | Tolerations for controller pods.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `[]`        |
 | `controller.affinity`                         | Specifies pod affinity for controller pods.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `{}`        |
 
+### Management Controller
+
+| Name                                | Description                                                             | Value  |
+| ----------------------------------- | ----------------------------------------------------------------------- | ------ |
+| `managementController.enabled`      | Whether the management controller is enabled.                           | `true` |
+| `managementController.logLevel`     | The log level for the management controller.                            | `INFO` |
+| `managementController.resources`    | Resources limits and requests for the management controller containers. | `{}`   |
+| `managementController.nodeSelector` | Node selector for management controller pods.                           | `{}`   |
+| `managementController.tolerations`  | Tolerations for management controller pods.                             | `[]`   |
+| `managementController.affinity`     | Specifies pod affinity for management controller pods.                  | `{}`   |
+
 ### Webhooks
 
 | Name                | Description                                                                                      | Value  |
diff --git a/charts/kargo/crds/kargo.akuity.io_projects.yaml b/charts/kargo/crds/kargo.akuity.io_projects.yaml
new file mode 100644
index 00000000..e9409be1
--- /dev/null
+++ b/charts/kargo/crds/kargo.akuity.io_projects.yaml
@@ -0,0 +1,51 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.13.0
+  name: projects.kargo.akuity.io
+spec:
+  group: kargo.akuity.io
+  names:
+    kind: Project
+    listKind: ProjectList
+    plural: projects
+    singular: project
+  scope: Cluster
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: 'Project is a resource type that reconciles to a specially labeled
+          namespace and other TODO: TBD project-level resources.'
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: Spec describes a Project.
+            type: object
+          status:
+            description: Status describes the Project's current status.
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/charts/kargo/templates/_helpers.tpl b/charts/kargo/templates/_helpers.tpl
index 019444b0..07f2e991 100644
--- a/charts/kargo/templates/_helpers.tpl
+++ b/charts/kargo/templates/_helpers.tpl
@@ -46,18 +46,22 @@ app.kubernetes.io/instance: {{ .Release.Name }}
 app.kubernetes.io/component: api
 {{- end -}}
 
-{{- define "kargo.dexServer.labels" -}}
-app.kubernetes.io/component: dex-server
-{{- end -}}
-
 {{- define "kargo.controller.labels" -}}
 app.kubernetes.io/component: controller
 {{- end -}}
 
+{{- define "kargo.dexServer.labels" -}}
+app.kubernetes.io/component: dex-server
+{{- end -}}
+
 {{- define "kargo.garbageCollector.labels" -}}
 app.kubernetes.io/component: garbage-collector
 {{- end -}}
 
+{{- define "kargo.managementController.labels" -}}
+app.kubernetes.io/component: management-controller
+{{- end -}}
+
 {{- define "kargo.webhooksServer.labels" -}}
 app.kubernetes.io/component: webhooks-server
 {{- end -}}
diff --git a/charts/kargo/templates/controller/configmap.yaml b/charts/kargo/templates/controller/configmap.yaml
index 04ca9b28..5ff7e348 100644
--- a/charts/kargo/templates/controller/configmap.yaml
+++ b/charts/kargo/templates/controller/configmap.yaml
@@ -15,9 +15,7 @@ data:
   {{- if .Values.kubeconfigSecrets.kargo }}
   KUBECONFIG: /etc/kargo/kubeconfigs/kubeconfig.yaml
   {{- end }}
-  {{- if .Values.controller.globalCredentials.enabled }}
   GLOBAL_CREDENTIALS_NAMESPACES: {{ join "," .Values.controller.globalCredentials.namespaces }}
-  {{- end }}
   {{- if .Values.kubeconfigSecrets.argocd }}
   ARGOCD_KUBECONFIG: /etc/kargo/kubeconfigs/argocd-kubeconfig.yaml
   {{- end }}
diff --git a/charts/kargo/templates/management-controller/cluster-role-bindings.yaml b/charts/kargo/templates/management-controller/cluster-role-bindings.yaml
new file mode 100644
index 00000000..64cfaace
--- /dev/null
+++ b/charts/kargo/templates/management-controller/cluster-role-bindings.yaml
@@ -0,0 +1,17 @@
+{{- if and .Values.managementController.enabled .Values.rbac.installClusterRoleBindings }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: kargo-management-controller
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.managementController.labels" . | nindent 4 }}
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: kargo-management-controller
+subjects:
+- kind: ServiceAccount
+  namespace: {{ .Release.Namespace }}
+  name: kargo-management-controller
+{{- end }}
diff --git a/charts/kargo/templates/management-controller/cluster-roles.yaml b/charts/kargo/templates/management-controller/cluster-roles.yaml
new file mode 100644
index 00000000..2cc3ced8
--- /dev/null
+++ b/charts/kargo/templates/management-controller/cluster-roles.yaml
@@ -0,0 +1,18 @@
+{{- if and .Values.managementController.enabled .Values.rbac.installClusterRoles }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: kargo-management-controller
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.managementController.labels" . | nindent 4 }}
+rules:
+- apiGroups:
+  - kargo.akuity.io
+  resources:
+  - projects
+  verbs:
+  - get
+  - list
+  - watch
+{{- end }}  
diff --git a/charts/kargo/templates/management-controller/configmap.yaml b/charts/kargo/templates/management-controller/configmap.yaml
new file mode 100644
index 00000000..d9cc5b92
--- /dev/null
+++ b/charts/kargo/templates/management-controller/configmap.yaml
@@ -0,0 +1,15 @@
+{{- if .Values.managementController.enabled }}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: kargo-management-controller
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.managementController.labels" . | nindent 4 }}
+data:
+  LOG_LEVEL: {{ .Values.managementController.logLevel }}
+  {{- if .Values.kubeconfigSecrets.kargo }}
+  KUBECONFIG: /etc/kargo/kubeconfigs/kubeconfig.yaml
+  {{- end }}
+{{- end }}
diff --git a/charts/kargo/templates/management-controller/deployment.yaml b/charts/kargo/templates/management-controller/deployment.yaml
new file mode 100644
index 00000000..c4caced5
--- /dev/null
+++ b/charts/kargo/templates/management-controller/deployment.yaml
@@ -0,0 +1,67 @@
+{{- if .Values.managementController.enabled }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: kargo-management-controller
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.managementController.labels" . | nindent 4 }}
+spec:
+  replicas: 1
+  strategy:
+    type: Recreate
+  selector:
+    matchLabels:
+      {{- include "kargo.selectorLabels" . | nindent 6 }}
+      {{- include "kargo.managementController.labels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        {{- include "kargo.selectorLabels" . | nindent 8 }}
+        {{- include "kargo.managementController.labels" . | nindent 8 }}
+      annotations:
+        configmap/checksum: {{ include (print $.Template.BasePath "/management-controller/configmap.yaml") . | sha256sum }}
+    spec:
+      serviceAccount: kargo-management-controller
+      {{- with .Values.managementController.affinity }}
+      affinity:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      containers:
+      - name: management-controller
+        image: {{ include "kargo.image" . }}
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        command: ["/usr/local/bin/kargo", "management-controller"]
+        envFrom:
+        - configMapRef:
+            name: kargo-management-controller
+        {{- if .Values.kubeconfigSecrets.kargo }}
+        volumeMounts:
+        - mountPath: /etc/kargo/kubeconfigs
+          name: kubeconfigs
+          readOnly: true
+        {{- end }}
+        resources:
+          {{- toYaml .Values.managementController.resources | nindent 10 }}
+      {{- if .Values.kubeconfigSecrets.kargo }}
+      volumes:
+      - name: kubeconfigs
+        projected:
+          sources:
+          - secret:
+              name: {{ .Values.kubeconfigSecrets.kargo }}
+              items:
+              - key: kubeconfig.yaml
+                path: kubeconfig.yaml
+                mode: 0644
+      {{- end }}
+      {{- with .Values.managementController.nodeSelector }}
+      nodeSelector:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.managementController.tolerations }}
+      tolerations:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+{{- end }}
diff --git a/charts/kargo/templates/management-controller/service-account.yaml b/charts/kargo/templates/management-controller/service-account.yaml
new file mode 100644
index 00000000..258dd27d
--- /dev/null
+++ b/charts/kargo/templates/management-controller/service-account.yaml
@@ -0,0 +1,10 @@
+{{- if .Values.managementController.enabled }}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: kargo-management-controller
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.managementController.labels" . | nindent 4 }}
+{{- end }}
diff --git a/charts/kargo/values.yaml b/charts/kargo/values.yaml
index c2bb1b98..a354bf7e 100755
--- a/charts/kargo/values.yaml
+++ b/charts/kargo/values.yaml
@@ -207,8 +207,6 @@ controller:
 
   ## All settings relating to shared credentials (used across multiple kargo projects)
   globalCredentials:
-    ## @param controller.globalCredentials.enabled Whether to enable the controller to look for credentials stored in specific namespaces used to share credentials.
-    enabled: false
     ## @param controller.globalCredentials.namespaces List of namespaces to look for shared credentials.
     namespaces: []
 
@@ -243,6 +241,30 @@ controller:
   ## @param controller.affinity Specifies pod affinity for controller pods.
   affinity: {}
 
+## @section Management Controller
+## All settings for the management controller component
+managementController:
+  ## @param managementController.enabled Whether the management controller is enabled.
+  enabled: true
+
+  ## @param managementController.logLevel The log level for the management controller.
+  logLevel: INFO
+
+  ## @param managementController.resources Resources limits and requests for the management controller containers.
+  resources: {}
+    # limits:
+    #   cpu: 100m
+    #   memory: 128Mi
+    # requests:
+    #   cpu: 100m
+    #   memory: 128Mi
+  ## @param managementController.nodeSelector Node selector for management controller pods.
+  nodeSelector: {}
+  ## @param managementController.tolerations Tolerations for management controller pods.
+  tolerations: []
+  ## @param managementController.affinity Specifies pod affinity for management controller pods.
+  affinity: {}
+
 ## @section Webhooks
 webhooks:
   ## @param webhooks.register Whether to create `ValidatingWebhookConfiguration` and `MutatingWebhookConfiguration` resources.
diff --git a/cmd/cli/main.go b/cmd/cli/main.go
index 33bbc6a4..68da63f3 100644
--- a/cmd/cli/main.go
+++ b/cmd/cli/main.go
@@ -21,7 +21,7 @@ func main() {
 		}
 		cfg = config.NewDefaultCLIConfig()
 	}
-	cmd, err := NewRootCommand(option.NewOption(cfg), &rootState{})
+	cmd, err := NewRootCommand(cfg, option.NewOption(cfg), &rootState{})
 	if err != nil {
 		fmt.Fprintln(os.Stderr, errors.Wrap(err, "new root command"))
 		os.Exit(1)
diff --git a/cmd/cli/root.go b/cmd/cli/root.go
index 9841ae4b..84f386c8 100644
--- a/cmd/cli/root.go
+++ b/cmd/cli/root.go
@@ -16,6 +16,7 @@ import (
 	apiconfig "github.com/akuity/kargo/internal/api/config"
 	"github.com/akuity/kargo/internal/api/kubernetes"
 	"github.com/akuity/kargo/internal/cli/cmd/apply"
+	"github.com/akuity/kargo/internal/cli/cmd/approve"
 	cliconfigcmd "github.com/akuity/kargo/internal/cli/cmd/config"
 	"github.com/akuity/kargo/internal/cli/cmd/create"
 	"github.com/akuity/kargo/internal/cli/cmd/delete"
@@ -25,6 +26,7 @@ import (
 	"github.com/akuity/kargo/internal/cli/cmd/refresh"
 	"github.com/akuity/kargo/internal/cli/cmd/stage"
 	"github.com/akuity/kargo/internal/cli/cmd/update"
+	clicfg "github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 )
 
@@ -33,7 +35,11 @@ type rootState struct {
 	localServerListener net.Listener
 }
 
-func NewRootCommand(opt *option.Option, rs *rootState) (*cobra.Command, error) {
+func NewRootCommand(
+	cfg clicfg.CLIConfig,
+	opt *option.Option,
+	rs *rootState,
+) (*cobra.Command, error) {
 	cmd := &cobra.Command{
 		Use:               "kargo",
 		DisableAutoGenTag: true,
@@ -89,20 +95,19 @@ func NewRootCommand(opt *option.Option, rs *rootState) (*cobra.Command, error) {
 		return nil, err
 	}
 	opt.PrintFlags = genericclioptions.NewPrintFlags("").WithTypeSetter(scheme)
-	option.InsecureTLS(&opt.InsecureTLS)(cmd.PersistentFlags())
-	option.LocalServer(&opt.UseLocalServer)(cmd.PersistentFlags())
 
-	cmd.AddCommand(apply.NewCommand(opt))
-	cmd.AddCommand(cliconfigcmd.NewCommand())
-	cmd.AddCommand(create.NewCommand(opt))
-	cmd.AddCommand(delete.NewCommand(opt))
-	cmd.AddCommand(get.NewCommand(opt))
+	cmd.AddCommand(apply.NewCommand(cfg, opt))
+	cmd.AddCommand(approve.NewCommand(cfg, opt))
+	cmd.AddCommand(cliconfigcmd.NewCommand(cfg))
+	cmd.AddCommand(create.NewCommand(cfg, opt))
+	cmd.AddCommand(delete.NewCommand(cfg, opt))
+	cmd.AddCommand(get.NewCommand(cfg, opt))
 	cmd.AddCommand(login.NewCommand(opt))
 	cmd.AddCommand(logout.NewCommand())
-	cmd.AddCommand(stage.NewCommand(opt))
-	cmd.AddCommand(refresh.NewCommand(opt))
-	cmd.AddCommand(update.NewCommand(opt))
-	cmd.AddCommand(newVersionCommand(opt))
+	cmd.AddCommand(stage.NewCommand(cfg, opt))
+	cmd.AddCommand(refresh.NewCommand(cfg, opt))
+	cmd.AddCommand(update.NewCommand(cfg, opt))
+	cmd.AddCommand(newVersionCommand(cfg, opt))
 	cmd.AddCommand(
 		cobracompletefig.CreateCompletionSpecCommand(
 			cobracompletefig.Opts{
diff --git a/cmd/cli/version.go b/cmd/cli/version.go
index 31247ac8..27ea7ae0 100644
--- a/cmd/cli/version.go
+++ b/cmd/cli/version.go
@@ -15,12 +15,16 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	versionpkg "github.com/akuity/kargo/internal/version"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newVersionCommand(opt *option.Option) *cobra.Command {
+func newVersionCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use: "version",
 		RunE: func(cmd *cobra.Command, args []string) error {
@@ -28,14 +32,15 @@ func newVersionCommand(opt *option.Option) *cobra.Command {
 
 			var serverVersion *svcv1alpha1.VersionInfo
 			if !opt.UseLocalServer {
-				kargoCli, err := client.GetClientFromConfig(ctx, opt)
-				if err != nil {
-					if !client.IsConfigNotFoundErr(err) {
-						return errors.Wrap(err, "get Kargo client from config")
+				if cfg.APIAddress != "" && cfg.BearerToken != "" {
+					kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
+					if err != nil {
+						return errors.Wrap(err, "get client from config")
 					}
-					// Skip initializing server version if config not found (not logged in).
-				} else {
-					resp, err := kargoCli.GetVersionInfo(ctx, connect.NewRequest(&svcv1alpha1.GetVersionInfoRequest{}))
+					resp, err := kargoSvcCli.GetVersionInfo(
+						ctx,
+						connect.NewRequest(&svcv1alpha1.GetVersionInfoRequest{}),
+					)
 					if err != nil {
 						return errors.Wrap(err, "get version info from server")
 					}
@@ -67,6 +72,7 @@ func newVersionCommand(opt *option.Option) *cobra.Command {
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
 	return cmd
 }
 
diff --git a/cmd/controlplane/controller.go b/cmd/controlplane/controller.go
index 11126737..7f457a5b 100644
--- a/cmd/controlplane/controller.go
+++ b/cmd/controlplane/controller.go
@@ -10,6 +10,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/cache"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/manager"
 	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
 
@@ -232,15 +233,16 @@ func newControllerCommand() *cobra.Command {
 				}
 			}
 
-			credentialsDbOpts := make([]credentials.KubernetesDatabaseOption, 0, 1)
+			var argoClientForCreds client.Client
 			if types.MustParseBool(
 				os.GetEnv("ARGOCD_ENABLE_CREDENTIAL_BORROWING", "false"),
 			) {
-				credentialsDbOpts = append(credentialsDbOpts, credentials.WithArgoClient(argocdMgr.GetClient()))
+				argoClientForCreds = argocdMgr.GetClient()
 			}
 			credentialsDB := credentials.NewKubernetesDatabase(
 				kargoMgr.GetClient(),
-				credentialsDbOpts...,
+				argoClientForCreds,
+				credentials.KubernetesDatabaseConfigFromEnv(),
 			)
 
 			if err := analysis.SetupReconcilerWithManager(
diff --git a/cmd/controlplane/management_controller.go b/cmd/controlplane/management_controller.go
new file mode 100644
index 00000000..10ea29e4
--- /dev/null
+++ b/cmd/controlplane/management_controller.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/manager"
+	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api/kubernetes"
+	"github.com/akuity/kargo/internal/controller/management/projects"
+	"github.com/akuity/kargo/internal/os"
+	versionpkg "github.com/akuity/kargo/internal/version"
+)
+
+func newManagementControllerCommand() *cobra.Command {
+	return &cobra.Command{
+		Use:               "management-controller",
+		DisableAutoGenTag: true,
+		SilenceErrors:     true,
+		SilenceUsage:      true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+
+			version := versionpkg.GetVersion()
+
+			log.WithFields(log.Fields{
+				"version": version.Version,
+				"commit":  version.GitCommit,
+			}).Info("Starting Kargo Management Controller")
+
+			var kargoMgr manager.Manager
+			{
+				restCfg, err :=
+					kubernetes.GetRestConfig(ctx, os.GetEnv("KUBECONFIG", ""))
+				if err != nil {
+					return errors.Wrap(
+						err,
+						"error loading REST config for Kargo controller manager",
+					)
+				}
+				restCfg.ContentType = runtime.ContentTypeJSON
+
+				scheme := runtime.NewScheme()
+				if err = corev1.AddToScheme(scheme); err != nil {
+					return errors.Wrap(
+						err,
+						"error adding Kubernetes core API to Kargo controller manager "+
+							"scheme",
+					)
+				}
+				if err = kargoapi.AddToScheme(scheme); err != nil {
+					return errors.Wrap(
+						err,
+						"error adding Kargo API to Kargo controller manager scheme",
+					)
+				}
+				if kargoMgr, err = ctrl.NewManager(
+					restCfg,
+					ctrl.Options{
+						Scheme: scheme,
+						Metrics: server.Options{
+							BindAddress: "0",
+						},
+					},
+				); err != nil {
+					return errors.Wrap(err, "error initializing Kargo controller manager")
+				}
+			}
+
+			if err := projects.SetupReconcilerWithManager(kargoMgr); err != nil {
+				return errors.Wrap(err, "error setting up Projects reconciler")
+			}
+
+			return errors.Wrap(kargoMgr.Start(ctx), "error starting kargo manager")
+		},
+	}
+}
diff --git a/cmd/controlplane/root.go b/cmd/controlplane/root.go
index 0b14e6cf..ac10eac6 100644
--- a/cmd/controlplane/root.go
+++ b/cmd/controlplane/root.go
@@ -22,6 +22,7 @@ func Execute(ctx context.Context) error {
 	rootCmd.AddCommand(newAPICommand())
 	rootCmd.AddCommand(newControllerCommand())
 	rootCmd.AddCommand(newGarbageCollectorCommand())
+	rootCmd.AddCommand(newManagementControllerCommand())
 	rootCmd.AddCommand(newVersionCommand())
 	rootCmd.AddCommand(newWebhooksServerCommand())
 	return rootCmd.ExecuteContext(ctx)
diff --git a/docs/docs/30-how-to-guides/20-managing-credentials.md b/docs/docs/30-how-to-guides/20-managing-credentials.md
index 5893b1d8..cac931a2 100644
--- a/docs/docs/30-how-to-guides/20-managing-credentials.md
+++ b/docs/docs/30-how-to-guides/20-managing-credentials.md
@@ -76,9 +76,8 @@ fully-supported at this time.
 
 In cases where one or more sets of credentials are needed widely across _all_
 Kargo projects, the administrator/operator installing Kargo may opt-in to
-designating one or more namespaces as homes for "global" credentials using
-the `controller.globalCredentials.enabled` and 
-`controller.globalCredentials.namespaces` settings in Kargo's Helm chart.
+designating one or more namespaces as homes for "global" credentials using the
+`controller.globalCredentials.namespaces` setting in Kargo's Helm chart.
 Refer to
 [the advanced section of the installation guide](./10-installing-kargo.md#advanced-installation)
 for more details.
diff --git a/docs/plugins/gtag/pnpm-lock.yaml b/docs/plugins/gtag/pnpm-lock.yaml
index c0643d29..9b185536 100644
--- a/docs/plugins/gtag/pnpm-lock.yaml
+++ b/docs/plugins/gtag/pnpm-lock.yaml
@@ -2506,7 +2506,7 @@ packages:
   /axios@0.27.2:
     resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
     dependencies:
-      follow-redirects: 1.15.3
+      follow-redirects: 1.15.4
       form-data: 4.0.0
     transitivePeerDependencies:
       - debug
@@ -3877,8 +3877,8 @@ packages:
     hasBin: true
     dev: false
 
-  /follow-redirects@1.15.3:
-    resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==}
+  /follow-redirects@1.15.4:
+    resolution: {integrity: sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==}
     engines: {node: '>=4.0'}
     peerDependencies:
       debug: '*'
@@ -4425,7 +4425,7 @@ packages:
     engines: {node: '>=8.0.0'}
     dependencies:
       eventemitter3: 4.0.7
-      follow-redirects: 1.15.3
+      follow-redirects: 1.15.4
       requires-port: 1.0.0
     transitivePeerDependencies:
       - debug
diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml
index 3ab677c5..e93c9454 100644
--- a/docs/pnpm-lock.yaml
+++ b/docs/pnpm-lock.yaml
@@ -1,5 +1,9 @@
 lockfileVersion: '6.0'
 
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
 dependencies:
   '@cmfcmf/docusaurus-search-local':
     specifier: ^0.11.0
@@ -5001,8 +5005,8 @@ packages:
     resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==}
     hasBin: true
 
-  /follow-redirects@1.15.3:
-    resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==}
+  /follow-redirects@1.15.4:
+    resolution: {integrity: sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==}
     engines: {node: '>=4.0'}
     peerDependencies:
       debug: '*'
@@ -5578,7 +5582,7 @@ packages:
     engines: {node: '>=8.0.0'}
     dependencies:
       eventemitter3: 4.0.7
-      follow-redirects: 1.15.3
+      follow-redirects: 1.15.4
       requires-port: 1.0.0
     transitivePeerDependencies:
       - debug
@@ -9360,7 +9364,3 @@ packages:
   /zwitch@2.0.4:
     resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
     dev: false
-
-settings:
-  autoInstallPeers: true
-  excludeLinksFromLockfile: false
diff --git a/go.mod b/go.mod
index 03c7cddd..14b12d7e 100644
--- a/go.mod
+++ b/go.mod
@@ -25,7 +25,6 @@ require (
 	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/pkg/errors v0.9.1
 	github.com/rs/cors v1.10.1
-	github.com/samber/mo v1.11.0
 	github.com/sirupsen/logrus v1.9.3
 	github.com/spf13/cobra v1.8.0
 	github.com/spf13/pflag v1.0.5
@@ -33,10 +32,10 @@ require (
 	github.com/technosophos/moniker v0.0.0-20210218184952-3ea787d3943b
 	github.com/withfig/autocomplete-tools/integrations/cobra v1.2.1
 	go.uber.org/ratelimit v0.3.0
-	golang.org/x/crypto v0.17.0
+	golang.org/x/crypto v0.18.0
 	golang.org/x/exp v0.0.0-20230807204917-050eac23e9de
-	golang.org/x/net v0.19.0
-	golang.org/x/oauth2 v0.15.0
+	golang.org/x/net v0.20.0
+	golang.org/x/oauth2 v0.16.0
 	golang.org/x/sync v0.6.0
 	google.golang.org/grpc v1.60.1
 	google.golang.org/protobuf v1.32.0
@@ -137,8 +136,8 @@ require (
 	go.opentelemetry.io/otel/metric v1.19.0 // indirect
 	go.opentelemetry.io/otel/trace v1.19.0 // indirect
 	go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
-	golang.org/x/sys v0.15.0 // indirect
-	golang.org/x/term v0.15.0 // indirect
+	golang.org/x/sys v0.16.0 // indirect
+	golang.org/x/term v0.16.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
 	golang.org/x/time v0.3.0 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
diff --git a/go.sum b/go.sum
index 3c1979bc..7ee1ebef 100644
--- a/go.sum
+++ b/go.sum
@@ -314,8 +314,6 @@ github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
 github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/samber/mo v1.11.0 h1:ZOiSkrGGpNhVv/1dxP02risztdMTIwE8KSW9OG4k5bY=
-github.com/samber/mo v1.11.0/go.mod h1:BfkrCPuYzVG3ZljnZB783WIJIGk1mcZr9c9CPf8tAxs=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@@ -382,8 +380,8 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
-golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
+golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20230807204917-050eac23e9de h1:l5Za6utMv/HsBWWqzt4S8X17j+kt1uVETUX5UFhn2rE=
 golang.org/x/exp v0.0.0-20230807204917-050eac23e9de/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
@@ -407,11 +405,11 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
-golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
+golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
+golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
-golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
+golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
+golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -440,13 +438,13 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
-golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
+golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
diff --git a/internal/api/approve_freight_v1alpha1.go b/internal/api/approve_freight_v1alpha1.go
index 5acf8264..34a7a29c 100644
--- a/internal/api/approve_freight_v1alpha1.go
+++ b/internal/api/approve_freight_v1alpha1.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"connectrpc.com/connect"
+	"github.com/pkg/errors"
 	kubeerr "k8s.io/apimachinery/pkg/api/errors"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -37,7 +38,7 @@ func (s *server) ApproveFreight(
 			return nil, connect.NewError(connect.CodeNotFound,
 				fmt.Errorf("freight %q not found", freightKey.String()))
 		}
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get freight")
 	}
 
 	var stage kargoapi.Stage
@@ -50,7 +51,7 @@ func (s *server) ApproveFreight(
 			return nil, connect.NewError(connect.CodeNotFound,
 				fmt.Errorf("stage %q not found", key.String()))
 		}
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get stage")
 	}
 
 	newStatus := *freight.Status.DeepCopy()
@@ -72,7 +73,7 @@ func (s *server) ApproveFreight(
 			*status = newStatus
 		},
 	); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "patch status")
 	}
 
 	return &connect.Response[svcv1alpha1.ApproveFreightResponse]{}, nil
diff --git a/internal/api/create_project_v1alpha1.go b/internal/api/create_project_v1alpha1.go
index 83dd659e..2bbb5268 100644
--- a/internal/api/create_project_v1alpha1.go
+++ b/internal/api/create_project_v1alpha1.go
@@ -28,8 +28,7 @@ func (s *server) CreateProject(
 	var existingNs corev1.Namespace
 	if err := s.client.Get(ctx, client.ObjectKey{Name: name}, &existingNs); err == nil || !kubeerr.IsNotFound(err) {
 		if err != nil {
-			return nil, connect.NewError(connect.CodeInternal,
-				errors.Wrap(err, "get existing namespace"))
+			return nil, errors.Wrap(err, "get namespace")
 		}
 		if existingNs.GetLabels()[kargoapi.LabelProjectKey] == kargoapi.LabelTrueValue {
 			return nil, connect.NewError(connect.CodeAlreadyExists,
@@ -48,7 +47,7 @@ func (s *server) CreateProject(
 		},
 	}
 	if err := s.client.Create(ctx, &ns); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "create namespace")
 	}
 	return connect.NewResponse(&svcv1alpha1.CreateProjectResponse{
 		Project: &svcv1alpha1.Project{
diff --git a/internal/api/create_promotion_policy_v1alpha1.go b/internal/api/create_promotion_policy_v1alpha1.go
index 9bf6bf71..c4a680f3 100644
--- a/internal/api/create_promotion_policy_v1alpha1.go
+++ b/internal/api/create_promotion_policy_v1alpha1.go
@@ -50,7 +50,7 @@ func (s *server) CreatePromotionPolicy(
 		if kubeerr.IsAlreadyExists(err) {
 			return nil, connect.NewError(connect.CodeAlreadyExists, err)
 		}
-		return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create promotion policy"))
+		return nil, errors.Wrap(err, "create promotion policy")
 	}
 	return connect.NewResponse(&svcv1alpha1.CreatePromotionPolicyResponse{
 		PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
diff --git a/internal/api/create_stage_v1alpha1.go b/internal/api/create_stage_v1alpha1.go
index 76dc7b35..ad55e1fd 100644
--- a/internal/api/create_stage_v1alpha1.go
+++ b/internal/api/create_stage_v1alpha1.go
@@ -49,7 +49,7 @@ func (s *server) CreateStage(
 		if kubeerr.IsAlreadyExists(err) {
 			return nil, connect.NewError(connect.CodeAlreadyExists, err)
 		}
-		return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create stage"))
+		return nil, errors.Wrap(err, "create stage")
 	}
 	return connect.NewResponse(&svcv1alpha1.CreateStageResponse{
 		Stage: typesv1alpha1.ToStageProto(stage),
diff --git a/internal/api/create_warehouse_v1alpha1.go b/internal/api/create_warehouse_v1alpha1.go
index 8e42b850..d47d7b14 100644
--- a/internal/api/create_warehouse_v1alpha1.go
+++ b/internal/api/create_warehouse_v1alpha1.go
@@ -46,7 +46,7 @@ func (s *server) CreateWarehouse(
 		if kubeerr.IsAlreadyExists(err) {
 			return nil, connect.NewError(connect.CodeAlreadyExists, err)
 		}
-		return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create warehouse"))
+		return nil, errors.Wrap(err, "create warehouse")
 	}
 	return connect.NewResponse(&svcv1alpha1.CreateWarehouseResponse{
 		Warehouse: typesv1alpha1.ToWarehouseProto(warehouse),
diff --git a/internal/api/delete_project_v1alpha1.go b/internal/api/delete_project_v1alpha1.go
index a96dc828..9a295a79 100644
--- a/internal/api/delete_project_v1alpha1.go
+++ b/internal/api/delete_project_v1alpha1.go
@@ -29,14 +29,14 @@ func (s *server) DeleteProject(
 			return nil, connect.NewError(connect.CodeNotFound,
 				errors.Errorf("project %q not found", name))
 		}
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get namespace")
 	}
 	if ns.GetLabels()[kargoapi.LabelProjectKey] != kargoapi.LabelTrueValue {
 		return nil, connect.NewError(connect.CodeFailedPrecondition,
 			errors.Errorf("namespace %q is not a project", ns.GetName()))
 	}
 	if err := s.client.Delete(ctx, &ns); err != nil && !kubeerr.IsNotFound(err) {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "delete namespace")
 	}
 	return connect.NewResponse(&svcv1alpha1.DeleteProjectResponse{
 		/* explicitly empty */
diff --git a/internal/api/delete_promotion_policy_v1alpha1.go b/internal/api/delete_promotion_policy_v1alpha1.go
index e228c015..4fe98458 100644
--- a/internal/api/delete_promotion_policy_v1alpha1.go
+++ b/internal/api/delete_promotion_policy_v1alpha1.go
@@ -37,10 +37,10 @@ func (s *server) DeletePromotionPolicy(
 			return nil, connect.NewError(connect.CodeNotFound,
 				fmt.Errorf("promotion policy %q not found", key.String()))
 		}
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get promotion policy")
 	}
 	if err := s.client.Delete(ctx, &policy); err != nil && !kubeerr.IsNotFound(err) {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "delete promotion policy")
 	}
 	return connect.NewResponse(&svcv1alpha1.DeletePromotionPolicyResponse{}), nil
 }
diff --git a/internal/api/delete_stage_v1alpha1.go b/internal/api/delete_stage_v1alpha1.go
index 39e4324a..fbb21c20 100644
--- a/internal/api/delete_stage_v1alpha1.go
+++ b/internal/api/delete_stage_v1alpha1.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"connectrpc.com/connect"
+	"github.com/pkg/errors"
 	kubeerr "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/fields"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -35,7 +36,7 @@ func (s *server) DeleteStage(
 			return nil, connect.NewError(connect.CodeNotFound,
 				fmt.Errorf("stage %q not found", key.String()))
 		}
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get stage")
 	}
 
 	approvedFreightList := kargoapi.FreightList{}
@@ -52,7 +53,7 @@ func (s *server) DeleteStage(
 	)
 
 	if err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "list freight")
 	}
 
 	for i := range approvedFreightList.Items {
@@ -73,12 +74,12 @@ func (s *server) DeleteStage(
 				*status = newStatus
 			},
 		); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
+			return nil, errors.Wrap(err, "patch status")
 		}
 	}
 
 	if err := s.client.Delete(ctx, &stage); err != nil && !kubeerr.IsNotFound(err) {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "delete stage")
 	}
 	return connect.NewResponse(&svcv1alpha1.DeleteStageResponse{}), nil
 }
diff --git a/internal/api/delete_warehouse_v1alpha1.go b/internal/api/delete_warehouse_v1alpha1.go
index 31cdebbc..25981739 100644
--- a/internal/api/delete_warehouse_v1alpha1.go
+++ b/internal/api/delete_warehouse_v1alpha1.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"connectrpc.com/connect"
+	"github.com/pkg/errors"
 	kubeerr "k8s.io/apimachinery/pkg/api/errors"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -32,10 +33,10 @@ func (s *server) DeleteWarehouse(
 			return nil, connect.NewError(connect.CodeNotFound,
 				fmt.Errorf("warehouse %q not found", key.String()))
 		}
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get warehouse")
 	}
 	if err := s.client.Delete(ctx, &warehouse); err != nil && !kubeerr.IsNotFound(err) {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "delete warehouse")
 	}
 	return connect.NewResponse(&svcv1alpha1.DeleteWarehouseResponse{}), nil
 }
diff --git a/internal/api/get_promotion_policy_v1alpha1.go b/internal/api/get_promotion_policy_v1alpha1.go
index 51fcb4ce..1ae76a7a 100644
--- a/internal/api/get_promotion_policy_v1alpha1.go
+++ b/internal/api/get_promotion_policy_v1alpha1.go
@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"connectrpc.com/connect"
+	"github.com/pkg/errors"
 	kubeerr "k8s.io/apimachinery/pkg/api/errors"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -32,7 +33,7 @@ func (s *server) GetPromotionPolicy(
 		if kubeerr.IsNotFound(err) {
 			return nil, connect.NewError(connect.CodeNotFound, err)
 		}
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get promotion policy")
 	}
 	return connect.NewResponse(&svcv1alpha1.GetPromotionPolicyResponse{
 		PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
diff --git a/internal/api/get_promotion_v1alpha1.go b/internal/api/get_promotion_v1alpha1.go
index a1438857..9e953f43 100644
--- a/internal/api/get_promotion_v1alpha1.go
+++ b/internal/api/get_promotion_v1alpha1.go
@@ -36,6 +36,7 @@ func (s *server) GetPromotion(
 			return nil, connect.NewError(connect.CodeNotFound,
 				errors.Errorf("promotion %q not found", req.Msg.GetName()))
 		}
+		return nil, errors.Wrap(err, "get promotion")
 	}
 	return connect.NewResponse(&svcv1alpha1.GetPromotionResponse{
 		Promotion: v1alpha1.ToPromotionProto(promotion),
diff --git a/internal/api/get_stage_v1alpha1.go b/internal/api/get_stage_v1alpha1.go
index c51ecfee..a47aa13e 100644
--- a/internal/api/get_stage_v1alpha1.go
+++ b/internal/api/get_stage_v1alpha1.go
@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"connectrpc.com/connect"
+	"github.com/pkg/errors"
 	kubeerr "k8s.io/apimachinery/pkg/api/errors"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -32,7 +33,7 @@ func (s *server) GetStage(
 		if kubeerr.IsNotFound(err) {
 			return nil, connect.NewError(connect.CodeNotFound, err)
 		}
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get stage")
 	}
 	return connect.NewResponse(&svcv1alpha1.GetStageResponse{
 		Stage: typesv1alpha1.ToStageProto(stage),
diff --git a/internal/api/get_warehouse_v1alpha1.go b/internal/api/get_warehouse_v1alpha1.go
index ec86c746..acdf895f 100644
--- a/internal/api/get_warehouse_v1alpha1.go
+++ b/internal/api/get_warehouse_v1alpha1.go
@@ -32,7 +32,7 @@ func (s *server) GetWarehouse(
 		if kubeerr.IsNotFound(err) {
 			return nil, connect.NewError(connect.CodeNotFound, err)
 		}
-		return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "get warehouse"))
+		return nil, errors.Wrap(err, "get warehouse")
 	}
 	return connect.NewResponse(&svcv1alpha1.GetWarehouseResponse{
 		Warehouse: typesv1alpha1.ToWarehouseProto(warehouse),
diff --git a/internal/api/list_projects_v1alpha1.go b/internal/api/list_projects_v1alpha1.go
index 6006b6d5..aeb5a350 100644
--- a/internal/api/list_projects_v1alpha1.go
+++ b/internal/api/list_projects_v1alpha1.go
@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"connectrpc.com/connect"
+	"github.com/pkg/errors"
 	"google.golang.org/protobuf/types/known/timestamppb"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/labels"
@@ -21,7 +22,7 @@ func (s *server) ListProjects(
 	selector := labels.Set{kargoapi.LabelProjectKey: kargoapi.LabelTrueValue}.AsSelector()
 	nsList := &corev1.NamespaceList{}
 	if err := s.client.List(ctx, nsList, client.MatchingLabelsSelector{Selector: selector}); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "list projects")
 	}
 
 	projects := make([]*svcv1alpha1.Project, len(nsList.Items))
diff --git a/internal/api/list_promotion_policies_v1alpha1.go b/internal/api/list_promotion_policies_v1alpha1.go
index 7f17f515..771ce4b4 100644
--- a/internal/api/list_promotion_policies_v1alpha1.go
+++ b/internal/api/list_promotion_policies_v1alpha1.go
@@ -26,7 +26,7 @@ func (s *server) ListPromotionPolicies(
 
 	var list kargoapi.PromotionPolicyList
 	if err := s.client.List(ctx, &list, client.InNamespace(req.Msg.GetProject())); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "list promotion policies")
 	}
 	policies := make([]*v1alpha1.PromotionPolicy, len(list.Items))
 	for idx, policy := range list.Items {
diff --git a/internal/api/list_promotions_v1alpha1.go b/internal/api/list_promotions_v1alpha1.go
index 806d9c4d..20abb2ff 100644
--- a/internal/api/list_promotions_v1alpha1.go
+++ b/internal/api/list_promotions_v1alpha1.go
@@ -36,7 +36,7 @@ func (s *server) ListPromotions(
 		)
 	}
 	if err := s.client.List(ctx, &list, opts...); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "list promotions")
 	}
 	promotions := make([]*v1alpha1.Promotion, len(list.Items))
 	for idx, promotion := range list.Items {
diff --git a/internal/api/list_stages_v1alpha1.go b/internal/api/list_stages_v1alpha1.go
index ff6c95ca..75f3c796 100644
--- a/internal/api/list_stages_v1alpha1.go
+++ b/internal/api/list_stages_v1alpha1.go
@@ -26,7 +26,7 @@ func (s *server) ListStages(
 
 	var list kargoapi.StageList
 	if err := s.client.List(ctx, &list, client.InNamespace(req.Msg.GetProject())); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "list stages")
 	}
 
 	stages := make([]*v1alpha1.Stage, len(list.Items))
diff --git a/internal/api/list_warehouses_v1alpha1.go b/internal/api/list_warehouses_v1alpha1.go
index 6a189e54..9f109051 100644
--- a/internal/api/list_warehouses_v1alpha1.go
+++ b/internal/api/list_warehouses_v1alpha1.go
@@ -26,7 +26,7 @@ func (s *server) ListWarehouses(
 
 	var list kargoapi.WarehouseList
 	if err := s.client.List(ctx, &list, client.InNamespace(req.Msg.GetProject())); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "list warehouses")
 	}
 
 	warehouses := make([]*v1alpha1.Warehouse, len(list.Items))
diff --git a/internal/api/option/error.go b/internal/api/option/error.go
new file mode 100644
index 00000000..bf209006
--- /dev/null
+++ b/internal/api/option/error.go
@@ -0,0 +1,93 @@
+package option
+
+import (
+	"context"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+)
+
+var (
+	_ connect.Interceptor = &errorInterceptor{}
+)
+
+type errorInterceptor struct{}
+
+func newErrorInterceptor() connect.Interceptor {
+	return &errorInterceptor{}
+}
+
+func (i *errorInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
+	return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
+		res, err := next(ctx, req)
+		if err != nil {
+			return nil, i.toConnectError(err)
+		}
+		return res, nil
+	}
+}
+
+func (i *errorInterceptor) WrapStreamingClient(
+	next connect.StreamingClientFunc,
+) connect.StreamingClientFunc {
+	// TODO: Support streaming client when necessary
+	return next
+}
+
+func (i *errorInterceptor) WrapStreamingHandler(
+	next connect.StreamingHandlerFunc,
+) connect.StreamingHandlerFunc {
+	return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
+		if err := next(ctx, conn); err != nil {
+			return i.toConnectError(err)
+		}
+		return nil
+	}
+}
+
+func (*errorInterceptor) toConnectError(err error) error {
+	var connectErr *connect.Error
+	if ok := errors.As(err, &connectErr); ok {
+		return err
+	}
+	var statusErr *kubeerr.StatusError
+	if ok := errors.As(err, &statusErr); ok {
+		return connect.NewError(httpStatusToConnectCode(statusErr.Status().Code), statusErr)
+	}
+	return connect.NewError(connect.CodeInternal, err)
+}
+
+func httpStatusToConnectCode(status int32) connect.Code {
+	switch status {
+	case http.StatusBadRequest:
+		return connect.CodeInvalidArgument
+	case http.StatusUnauthorized:
+		return connect.CodeUnauthenticated
+	case http.StatusForbidden:
+		return connect.CodePermissionDenied
+	case http.StatusNotFound:
+		return connect.CodeNotFound
+	case http.StatusConflict:
+		return connect.CodeAlreadyExists
+	case http.StatusGone:
+		return connect.CodeNotFound
+	case http.StatusUnprocessableEntity:
+		return connect.CodeInvalidArgument
+	case http.StatusTooManyRequests:
+		return connect.CodeResourceExhausted
+	case 499:
+		return connect.CodeCanceled
+	case http.StatusInternalServerError:
+		return connect.CodeInternal
+	case http.StatusNotImplemented:
+		return connect.CodeUnimplemented
+	case http.StatusServiceUnavailable:
+		return connect.CodeUnavailable
+	case http.StatusGatewayTimeout:
+		return connect.CodeDeadlineExceeded
+	default:
+		return connect.CodeInternal
+	}
+}
diff --git a/internal/api/option/option.go b/internal/api/option/option.go
index 1597181e..1e3b929f 100644
--- a/internal/api/option/option.go
+++ b/internal/api/option/option.go
@@ -21,12 +21,12 @@ func NewHandlerOption(
 ) (connect.HandlerOption, error) {
 	interceptors := []connect.Interceptor{
 		newLogInterceptor(logging.LoggerFromContext(ctx), loggingIgnorableMethods),
+		newErrorInterceptor(),
 	}
 	if !cfg.LocalMode {
 		authInterceptor, err := newAuthInterceptor(ctx, cfg, internalClient)
 		if err != nil {
-			return nil,
-				errors.Wrap(err, "error initializing authentication interceptor")
+			return nil, errors.Wrap(err, "initialize authentication interceptor")
 		}
 		interceptors = append(interceptors, authInterceptor)
 	}
diff --git a/internal/api/promote_stage_v1alpha1.go b/internal/api/promote_stage_v1alpha1.go
index fbe13224..0a532ce7 100644
--- a/internal/api/promote_stage_v1alpha1.go
+++ b/internal/api/promote_stage_v1alpha1.go
@@ -33,7 +33,7 @@ func (s *server) PromoteStage(
 		},
 	)
 	if err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get stage")
 	}
 	if stage == nil {
 		return nil, connect.NewError(
@@ -55,7 +55,7 @@ func (s *server) PromoteStage(
 		},
 	)
 	if err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get freight")
 	}
 	if freight == nil {
 		return nil, connect.NewError(
@@ -84,7 +84,7 @@ func (s *server) PromoteStage(
 
 	promotion := kargo.NewPromotion(*stage, req.Msg.GetFreight())
 	if err := s.createPromotionFn(ctx, &promotion); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "create promotion")
 	}
 	return connect.NewResponse(&svcv1alpha1.PromoteStageResponse{
 		Promotion: typesv1alpha1.ToPromotionProto(promotion),
diff --git a/internal/api/promote_subscribers_v1alpha1.go b/internal/api/promote_subscribers_v1alpha1.go
index 2cf5c8cc..25ac053e 100644
--- a/internal/api/promote_subscribers_v1alpha1.go
+++ b/internal/api/promote_subscribers_v1alpha1.go
@@ -40,7 +40,7 @@ func (s *server) PromoteSubscribers(
 		},
 	)
 	if err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get stage")
 	}
 	if stage == nil {
 		return nil, connect.NewError(
@@ -71,7 +71,7 @@ func (s *server) PromoteSubscribers(
 		},
 	)
 	if err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "get freight")
 	}
 	if freight == nil {
 		return nil, connect.NewError(
@@ -100,10 +100,10 @@ func (s *server) PromoteSubscribers(
 
 	subscribers, err := s.findStageSubscribersFn(ctx, stage)
 	if err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "find stage subscribers")
 	}
 	if len(subscribers) == 0 {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Stage %q has no subscribers", req.Msg.GetStage()))
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("stage %q has no subscribers", req.Msg.GetStage()))
 	}
 
 	promoteErrs := make([]error, 0, len(subscribers))
diff --git a/internal/api/query_freights_v1alpha1.go b/internal/api/query_freights_v1alpha1.go
index f96a1274..74960a2b 100644
--- a/internal/api/query_freights_v1alpha1.go
+++ b/internal/api/query_freights_v1alpha1.go
@@ -57,7 +57,7 @@ func (s *server) QueryFreight(
 			},
 		)
 		if err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
+			return nil, errors.Wrap(err, "get stage")
 		}
 		if stage == nil {
 			return nil, connect.NewError(
@@ -75,7 +75,7 @@ func (s *server) QueryFreight(
 			*stage.Spec.Subscriptions,
 		)
 		if err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
+			return nil, errors.Wrap(err, "get available freight for stage")
 		}
 	} else {
 		freightList := &kargoapi.FreightList{}
@@ -85,7 +85,7 @@ func (s *server) QueryFreight(
 			freightList,
 			client.InNamespace(req.Msg.GetProject()),
 		); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, err)
+			return nil, errors.Wrap(err, "list freights")
 		}
 		freight = freightList.Items
 	}
diff --git a/internal/api/set_auto_promotion_for_stage_v1alpha1.go b/internal/api/set_auto_promotion_for_stage_v1alpha1.go
index 9875691d..1cdb1326 100644
--- a/internal/api/set_auto_promotion_for_stage_v1alpha1.go
+++ b/internal/api/set_auto_promotion_for_stage_v1alpha1.go
@@ -32,7 +32,7 @@ func (s *server) SetAutoPromotionForStage(
 	if err := s.client.List(ctx, &policyList, client.InNamespace(req.Msg.GetProject()), client.MatchingFields{
 		kubeclient.PromotionPoliciesByStageIndexField: req.Msg.GetStage(),
 	}); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "list promotion policies"))
+		return nil, errors.Wrap(err, "list promotion policies")
 	}
 
 	// Since only one PromotionPolicy is allowed per stage,
@@ -42,7 +42,7 @@ func (s *server) SetAutoPromotionForStage(
 		policy = policyList.Items[0]
 		policy.EnableAutoPromotion = req.Msg.GetEnable()
 		if err := s.client.Update(ctx, &policy); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "update promotion policy"))
+			return nil, errors.Wrap(err, "update promotion policy")
 		}
 	} else {
 		policy = kargoapi.PromotionPolicy{
@@ -54,7 +54,7 @@ func (s *server) SetAutoPromotionForStage(
 			EnableAutoPromotion: req.Msg.GetEnable(),
 		}
 		if err := s.client.Create(ctx, &policy); err != nil {
-			return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "create promotion policy"))
+			return nil, errors.Wrap(err, "create promotion policy")
 		}
 	}
 	return connect.NewResponse(&svcv1alpha1.SetAutoPromotionForStageResponse{
diff --git a/internal/api/update_promotion_policy_v1alpha1.go b/internal/api/update_promotion_policy_v1alpha1.go
index e7441eae..c5fa8bc3 100644
--- a/internal/api/update_promotion_policy_v1alpha1.go
+++ b/internal/api/update_promotion_policy_v1alpha1.go
@@ -52,10 +52,11 @@ func (s *server) UpdatePromotionPolicy(
 		if kubeerr.IsNotFound(err) {
 			return nil, connect.NewError(connect.CodeNotFound, err)
 		}
+		return nil, errors.Wrap(err, "get promotion policy")
 	}
 	policy.SetResourceVersion(existingPolicy.GetResourceVersion())
 	if err := s.client.Update(ctx, &policy); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "update promotion policy")
 	}
 	return connect.NewResponse(&svcv1alpha1.UpdatePromotionPolicyResponse{
 		PromotionPolicy: typesv1alpha1.ToPromotionPolicyProto(policy),
diff --git a/internal/api/update_stage_v1alpha1.go b/internal/api/update_stage_v1alpha1.go
index 8ea1732c..d043817f 100644
--- a/internal/api/update_stage_v1alpha1.go
+++ b/internal/api/update_stage_v1alpha1.go
@@ -51,10 +51,11 @@ func (s *server) UpdateStage(
 		if kubeerr.IsNotFound(err) {
 			return nil, connect.NewError(connect.CodeNotFound, err)
 		}
+		return nil, errors.Wrap(err, "get stage")
 	}
 	stage.SetResourceVersion(existingStage.GetResourceVersion())
 	if err := s.client.Update(ctx, &stage); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "update stage")
 	}
 	return connect.NewResponse(&svcv1alpha1.UpdateStageResponse{
 		Stage: typesv1alpha1.ToStageProto(stage),
diff --git a/internal/api/update_warehouse_v1alpha1.go b/internal/api/update_warehouse_v1alpha1.go
index 92837e74..144eb85c 100644
--- a/internal/api/update_warehouse_v1alpha1.go
+++ b/internal/api/update_warehouse_v1alpha1.go
@@ -48,10 +48,11 @@ func (s *server) UpdateWarehouse(
 		if kubeerr.IsNotFound(err) {
 			return nil, connect.NewError(connect.CodeNotFound, err)
 		}
+		return nil, errors.Wrap(err, "get warehouse")
 	}
 	warehouse.SetResourceVersion(existingWarehouse.GetResourceVersion())
 	if err := s.client.Update(ctx, &warehouse); err != nil {
-		return nil, connect.NewError(connect.CodeInternal, err)
+		return nil, errors.Wrap(err, "update warehouse")
 	}
 	return connect.NewResponse(&svcv1alpha1.UpdateWarehouseResponse{
 		Warehouse: typesv1alpha1.ToWarehouseProto(warehouse),
diff --git a/internal/api/validators.go b/internal/api/validators.go
index f7ec6bdd..49d32478 100644
--- a/internal/api/validators.go
+++ b/internal/api/validators.go
@@ -19,7 +19,7 @@ func (s *server) validateProject(ctx context.Context, project string) error {
 		if ok := errors.As(err, &fieldErr); ok {
 			return connect.NewError(connect.CodeInvalidArgument, err)
 		}
-		return connect.NewError(connect.CodeInternal, err)
+		return errors.Wrap(err, "validate project")
 	}
 	return nil
 }
diff --git a/internal/api/watch_promotion_v1alpha1.go b/internal/api/watch_promotion_v1alpha1.go
index fe3d6c6c..18c1a074 100644
--- a/internal/api/watch_promotion_v1alpha1.go
+++ b/internal/api/watch_promotion_v1alpha1.go
@@ -39,7 +39,7 @@ func (s *server) WatchPromotion(
 		if kubeerr.IsNotFound(err) {
 			return connect.NewError(connect.CodeNotFound, err)
 		}
-		return connect.NewError(connect.CodeInternal, err)
+		return errors.Wrap(err, "get promotion")
 	}
 
 	opts := metav1.ListOptions{
diff --git a/internal/api/watch_promotions_v1alpha1.go b/internal/api/watch_promotions_v1alpha1.go
index 2aa27293..c163cf11 100644
--- a/internal/api/watch_promotions_v1alpha1.go
+++ b/internal/api/watch_promotions_v1alpha1.go
@@ -36,7 +36,7 @@ func (s *server) WatchPromotions(
 			if kubeerr.IsNotFound(err) {
 				return connect.NewError(connect.CodeNotFound, err)
 			}
-			return connect.NewError(connect.CodeInternal, err)
+			return errors.Wrap(err, "get stage")
 		}
 	}
 
diff --git a/internal/api/watch_stages_v1alpha1.go b/internal/api/watch_stages_v1alpha1.go
index 9cf4b29e..63f1f871 100644
--- a/internal/api/watch_stages_v1alpha1.go
+++ b/internal/api/watch_stages_v1alpha1.go
@@ -37,7 +37,7 @@ func (s *server) WatchStages(
 			if kubeerr.IsNotFound(err) {
 				return connect.NewError(connect.CodeNotFound, err)
 			}
-			return connect.NewError(connect.CodeInternal, err)
+			return errors.Wrap(err, "get stage")
 		}
 	}
 
diff --git a/internal/api/watch_warehouses_v1alpha1.go b/internal/api/watch_warehouses_v1alpha1.go
index e7dede43..7c9d8c93 100644
--- a/internal/api/watch_warehouses_v1alpha1.go
+++ b/internal/api/watch_warehouses_v1alpha1.go
@@ -37,7 +37,7 @@ func (s *server) WatchWarehouses(
 			if kubeerr.IsNotFound(err) {
 				return connect.NewError(connect.CodeNotFound, err)
 			}
-			return connect.NewError(connect.CodeInternal, err)
+			return errors.Wrap(err, "get warehouse")
 		}
 	}
 
diff --git a/internal/cli/client/client.go b/internal/cli/client/client.go
index 31534655..8550da97 100644
--- a/internal/cli/client/client.go
+++ b/internal/cli/client/client.go
@@ -17,25 +17,25 @@ import (
 // the address specified in local configuration, using credentials also
 // specified in local configuration UNLESS the specified options indicates that
 // the local server should be used instead.
-func GetClientFromConfig(ctx context.Context, opt *option.Option) (
+func GetClientFromConfig(
+	ctx context.Context,
+	cfg config.CLIConfig,
+	opt *option.Option,
+) (
 	svcv1alpha1connect.KargoServiceClient,
 	error,
 ) {
 	if opt.UseLocalServer {
 		return GetClient(opt.LocalServerAddress, "", opt.InsecureTLS), nil
 	}
-	cfg, err := config.LoadCLIConfig()
-	if err != nil {
-		return nil, errors.Wrap(err, "load cli config")
-	}
 	if cfg.APIAddress == "" || cfg.BearerToken == "" {
 		return nil, errors.New(
 			"seems like you are not logged in; please use `kargo login` to authenticate",
 		)
 	}
 	skipTLSVerify := opt.InsecureTLS || cfg.InsecureSkipTLSVerify
-	if cfg, err =
-		newTokenRefresher().refreshToken(ctx, cfg, skipTLSVerify); err != nil {
+	cfg, err := newTokenRefresher().refreshToken(ctx, cfg, skipTLSVerify)
+	if err != nil {
 		return nil, errors.Wrap(err, "error refreshing token")
 	}
 	return GetClient(cfg.APIAddress, cfg.BearerToken, skipTLSVerify), nil
diff --git a/internal/cli/cmd/apply/apply.go b/internal/cli/cmd/apply/apply.go
index 5a6394ed..f36a08d0 100644
--- a/internal/cli/cmd/apply/apply.go
+++ b/internal/cli/cmd/apply/apply.go
@@ -14,16 +14,13 @@ import (
 	sigyaml "sigs.k8s.io/yaml"
 
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	kargosvcapi "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-type Flags struct {
-	Filenames []string
-}
-
-func NewCommand(opt *option.Option) *cobra.Command {
-	var flag Flags
+func NewCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
+	var filenames []string
 	cmd := &cobra.Command{
 		Use:   "apply [--project=project] -f (FILENAME)",
 		Short: "Apply a resource from a file or from stdin",
@@ -33,11 +30,11 @@ kargo apply -f stage.yaml
 `,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
-			if len(flag.Filenames) == 0 {
+			if len(filenames) == 0 {
 				return errors.New("filename is required")
 			}
 
-			rawManifest, err := option.ReadManifests(flag.Filenames...)
+			rawManifest, err := option.ReadManifests(filenames...)
 			if err != nil {
 				return errors.Wrap(err, "read manifests")
 			}
@@ -50,7 +47,7 @@ kargo apply -f stage.yaml
 				}
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -118,6 +115,8 @@ kargo apply -f stage.yaml
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Filenames("apply", &flag.Filenames)(cmd.Flags())
+	option.Filenames(cmd.Flags(), &filenames, "apply")
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
+	option.LocalServer(cmd.PersistentFlags(), opt)
 	return cmd
 }
diff --git a/internal/cli/cmd/approve/approve.go b/internal/cli/cmd/approve/approve.go
new file mode 100644
index 00000000..846394d2
--- /dev/null
+++ b/internal/cli/cmd/approve/approve.go
@@ -0,0 +1,58 @@
+package approve
+
+import (
+	"connectrpc.com/connect"
+	"github.com/pkg/errors"
+	"github.com/spf13/cobra"
+
+	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
+	"github.com/akuity/kargo/internal/cli/option"
+	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+)
+
+func NewCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
+	var freight, stage string
+	cmd := &cobra.Command{
+		Use:     "approve --project=project --freight=freight --stage=stage",
+		Short:   "Manually approve freight for promotion to a stage",
+		Example: "kargo approve --project=project --freight=abc1234 --stage=qa",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			ctx := cmd.Context()
+
+			project := opt.Project
+			if project == "" {
+				return errors.New("project is required")
+			}
+			if freight == "" {
+				return errors.New("freight is required")
+			}
+			if stage == "" {
+				return errors.New("stage is required")
+			}
+
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
+			if err != nil {
+				return errors.Wrap(err, "get client from config")
+			}
+
+			if _, err = kargoSvcCli.ApproveFreight(
+				ctx,
+				connect.NewRequest(
+					&v1alpha1.ApproveFreightRequest{
+						Project: project,
+						Id:      freight,
+						Stage:   stage,
+					},
+				),
+			); err != nil {
+				return errors.Wrap(err, "approve freight")
+			}
+			return nil
+		},
+	}
+	option.Project(cmd.Flags(), opt, opt.Project)
+	option.Freight(cmd.Flags(), &freight)
+	option.Stage(cmd.Flags(), &stage)
+	return cmd
+}
diff --git a/internal/cli/cmd/config/config.go b/internal/cli/cmd/config/config.go
index 97465e6e..6a48ed57 100644
--- a/internal/cli/cmd/config/config.go
+++ b/internal/cli/cmd/config/config.go
@@ -1,15 +1,19 @@
 package config
 
-import "github.com/spf13/cobra"
+import (
+	"github.com/spf13/cobra"
 
-func NewCommand() *cobra.Command {
+	"github.com/akuity/kargo/internal/cli/config"
+)
+
+func NewCommand(cfg config.CLIConfig) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "config",
 		Short: "Manage Kargo CLI configuration",
 	}
 
 	// Subcommands
-	cmd.AddCommand(newSetCommand())
-	cmd.AddCommand(newUnsetCommand())
+	cmd.AddCommand(newSetCommand(cfg))
+	cmd.AddCommand(newUnsetCommand(cfg))
 	return cmd
 }
diff --git a/internal/cli/cmd/config/set.go b/internal/cli/cmd/config/set.go
index 02657e9e..19d63278 100644
--- a/internal/cli/cmd/config/set.go
+++ b/internal/cli/cmd/config/set.go
@@ -10,7 +10,7 @@ import (
 	"github.com/akuity/kargo/internal/cli/option"
 )
 
-func newSetCommand() *cobra.Command {
+func newSetCommand(cfg config.CLIConfig) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "set",
 		Short: "Set an individual value",
@@ -20,11 +20,6 @@ func newSetCommand() *cobra.Command {
 kargo config set project my-project
 `,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			cfg, err := config.LoadCLIConfig()
-			if err != nil {
-				return errors.Wrap(err, "ensure cli config")
-			}
-
 			key := strings.ToLower(args[0])
 			switch key {
 			case "project":
diff --git a/internal/cli/cmd/config/unset.go b/internal/cli/cmd/config/unset.go
index f8a96123..5c940a15 100644
--- a/internal/cli/cmd/config/unset.go
+++ b/internal/cli/cmd/config/unset.go
@@ -10,7 +10,7 @@ import (
 	"github.com/akuity/kargo/internal/cli/option"
 )
 
-func newUnsetCommand() *cobra.Command {
+func newUnsetCommand(cfg config.CLIConfig) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "unset",
 		Short: "Unset an individual value",
@@ -20,11 +20,6 @@ func newUnsetCommand() *cobra.Command {
 kargo config unset project my-project
 `,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			cfg, err := config.LoadCLIConfig()
-			if err != nil {
-				return errors.Wrap(err, "ensure cli config")
-			}
-
 			key := strings.ToLower(args[0])
 			switch key {
 			case "project":
diff --git a/internal/cli/cmd/create/create.go b/internal/cli/cmd/create/create.go
index 8f98dea9..1e316bb5 100644
--- a/internal/cli/cmd/create/create.go
+++ b/internal/cli/cmd/create/create.go
@@ -14,16 +14,13 @@ import (
 	sigyaml "sigs.k8s.io/yaml"
 
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	kargosvcapi "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-type Flags struct {
-	Filenames []string
-}
-
-func NewCommand(opt *option.Option) *cobra.Command {
-	var flag Flags
+func NewCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
+	var filenames []string
 	cmd := &cobra.Command{
 		Use:   "create [--project=project] -f (FILENAME)",
 		Short: "Create a resource from a file or from stdin",
@@ -36,11 +33,11 @@ kargo create project my-project
 `,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
-			if len(flag.Filenames) == 0 {
+			if len(filenames) == 0 {
 				return errors.New("filename is required")
 			}
 
-			manifest, err := option.ReadManifests(flag.Filenames...)
+			manifest, err := option.ReadManifests(filenames...)
 			if err != nil {
 				return errors.Wrap(err, "read manifests")
 			}
@@ -53,7 +50,7 @@ kargo create project my-project
 				}
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -96,9 +93,11 @@ kargo create project my-project
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Filenames("create", &flag.Filenames)(cmd.Flags())
+	option.Filenames(cmd.Flags(), &filenames, "apply")
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
+	option.LocalServer(cmd.PersistentFlags(), opt)
 
 	// Subcommands
-	cmd.AddCommand(newProjectCommand(opt))
+	cmd.AddCommand(newProjectCommand(cfg, opt))
 	return cmd
 }
diff --git a/internal/cli/cmd/create/project.go b/internal/cli/cmd/create/project.go
index 0c314bc1..b1e79fc7 100644
--- a/internal/cli/cmd/create/project.go
+++ b/internal/cli/cmd/create/project.go
@@ -13,11 +13,12 @@ import (
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	kargosvcapi "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newProjectCommand(opt *option.Option) *cobra.Command {
+func newProjectCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "project (NAME)",
 		Short: "Create a project",
@@ -34,7 +35,7 @@ kargo create project my-project
 				return errors.New("name is required")
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
diff --git a/internal/cli/cmd/delete/delete.go b/internal/cli/cmd/delete/delete.go
index fef6be33..93784dd5 100644
--- a/internal/cli/cmd/delete/delete.go
+++ b/internal/cli/cmd/delete/delete.go
@@ -13,16 +13,13 @@ import (
 	sigyaml "sigs.k8s.io/yaml"
 
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	kargosvcapi "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-type Flags struct {
-	Filenames []string
-}
-
-func NewCommand(opt *option.Option) *cobra.Command {
-	var flag Flags
+func NewCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
+	var filenames []string
 	cmd := &cobra.Command{
 		Use:   "delete [--project=project] -f (FILENAME)",
 		Short: "Delete resources by resources and names",
@@ -38,16 +35,16 @@ kargo delete -f stage.yaml
 `,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
-			if len(flag.Filenames) == 0 {
+			if len(filenames) == 0 {
 				return errors.New("filename is required")
 			}
 
-			manifest, err := option.ReadManifests(flag.Filenames...)
+			manifest, err := option.ReadManifests(filenames...)
 			if err != nil {
 				return errors.Wrap(err, "read manifests")
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -86,11 +83,13 @@ kargo delete -f stage.yaml
 			return goerrors.Join(deleteErrs...)
 		},
 	}
-	option.Filenames("delete", &flag.Filenames)(cmd.Flags())
+	option.Filenames(cmd.Flags(), &filenames, "apply")
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
+	option.LocalServer(cmd.PersistentFlags(), opt)
 
 	// Subcommands
-	cmd.AddCommand(newProjectCommand(opt))
-	cmd.AddCommand(newStageCommand(opt))
-	cmd.AddCommand(newWarehouseCommand(opt))
+	cmd.AddCommand(newProjectCommand(cfg, opt))
+	cmd.AddCommand(newStageCommand(cfg, opt))
+	cmd.AddCommand(newWarehouseCommand(cfg, opt))
 	return cmd
 }
diff --git a/internal/cli/cmd/delete/project.go b/internal/cli/cmd/delete/project.go
index e54fadd1..511dc308 100644
--- a/internal/cli/cmd/delete/project.go
+++ b/internal/cli/cmd/delete/project.go
@@ -10,11 +10,15 @@ import (
 	"golang.org/x/exp/slices"
 
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newProjectCommand(opt *option.Option) *cobra.Command {
+func newProjectCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "project [NAME]...",
 		Short: "Delete project by name",
@@ -25,7 +29,7 @@ kargo delete project my-project
 `,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -44,6 +48,6 @@ kargo delete project my-project
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
diff --git a/internal/cli/cmd/delete/stage.go b/internal/cli/cmd/delete/stage.go
index ccda32b2..b1a76acd 100644
--- a/internal/cli/cmd/delete/stage.go
+++ b/internal/cli/cmd/delete/stage.go
@@ -10,11 +10,12 @@ import (
 	"golang.org/x/exp/slices"
 
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newStageCommand(opt *option.Option) *cobra.Command {
+func newStageCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "stage [NAME]...",
 		Short: "Delete stage by name",
@@ -25,7 +26,7 @@ kargo delete stage --project=my-project my-stage
 `,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -50,6 +51,6 @@ kargo delete stage --project=my-project my-stage
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
diff --git a/internal/cli/cmd/delete/warehouse.go b/internal/cli/cmd/delete/warehouse.go
index b9e2957a..fe80158d 100644
--- a/internal/cli/cmd/delete/warehouse.go
+++ b/internal/cli/cmd/delete/warehouse.go
@@ -10,11 +10,15 @@ import (
 	"golang.org/x/exp/slices"
 
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newWarehouseCommand(opt *option.Option) *cobra.Command {
+func newWarehouseCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "warehouse [NAME]...",
 		Short: "Delete warehouse by name",
@@ -25,7 +29,7 @@ kargo delete warehouse --project=my-project my-warehouse
 `,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -55,6 +59,6 @@ kargo delete warehouse --project=my-project my-warehouse
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
diff --git a/internal/cli/cmd/get/freight.go b/internal/cli/cmd/get/freight.go
index 20b578f3..70a7bb98 100644
--- a/internal/cli/cmd/get/freight.go
+++ b/internal/cli/cmd/get/freight.go
@@ -14,11 +14,15 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newGetFreightCommand(opt *option.Option) *cobra.Command {
+func newGetFreightCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "freight --project=project [NAME...]",
 		Short: "Display one or many pieces of freight",
@@ -40,7 +44,7 @@ kargo get freight --project=my-project my-freight
 				return errors.New("project is required")
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -94,7 +98,7 @@ kargo get freight --project=my-project my-freight
 			return resErr
 		},
 	}
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	opt.PrintFlags.AddFlags(cmd)
 	return cmd
 }
diff --git a/internal/cli/cmd/get/get.go b/internal/cli/cmd/get/get.go
index 3fdb53a2..07a8e22a 100644
--- a/internal/cli/cmd/get/get.go
+++ b/internal/cli/cmd/get/get.go
@@ -9,10 +9,11 @@ import (
 	"k8s.io/utils/ptr"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 )
 
-func NewCommand(opt *option.Option) *cobra.Command {
+func NewCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "get (RESOURCE) [NAME]...",
 		Short: "Display one or many resources",
@@ -27,12 +28,15 @@ kargo get stages --project=my-project
 kargo get promotions --project=my-project --stage=my-stage
 `,
 	}
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
+	option.LocalServer(cmd.PersistentFlags(), opt)
+
 	// Subcommands
-	cmd.AddCommand(newGetFreightCommand(opt))
-	cmd.AddCommand(newGetProjectsCommand(opt))
-	cmd.AddCommand(newGetPromotionsCommand(opt))
-	cmd.AddCommand(newGetStagesCommand(opt))
-	cmd.AddCommand(newGetWarehousesCommand(opt))
+	cmd.AddCommand(newGetFreightCommand(cfg, opt))
+	cmd.AddCommand(newGetProjectsCommand(cfg, opt))
+	cmd.AddCommand(newGetPromotionsCommand(cfg, opt))
+	cmd.AddCommand(newGetStagesCommand(cfg, opt))
+	cmd.AddCommand(newGetWarehousesCommand(cfg, opt))
 	return cmd
 }
 
diff --git a/internal/cli/cmd/get/projects.go b/internal/cli/cmd/get/projects.go
index 3cd71544..357c0d53 100644
--- a/internal/cli/cmd/get/projects.go
+++ b/internal/cli/cmd/get/projects.go
@@ -11,11 +11,15 @@ import (
 
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newGetProjectsCommand(opt *option.Option) *cobra.Command {
+func newGetProjectsCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "projects [NAME...]",
 		Aliases: []string{"project"},
@@ -30,7 +34,7 @@ kargo get projects -o json
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
diff --git a/internal/cli/cmd/get/promotions.go b/internal/cli/cmd/get/promotions.go
index 82a374b6..bc734ffa 100644
--- a/internal/cli/cmd/get/promotions.go
+++ b/internal/cli/cmd/get/promotions.go
@@ -15,18 +15,16 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-type PromotionsFlags struct {
-	Stage option.Optional[string]
-}
-
-func newGetPromotionsCommand(opt *option.Option) *cobra.Command {
-	flag := PromotionsFlags{
-		Stage: option.OptionalString(),
-	}
+func newGetPromotionsCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
+	var stage string
 	cmd := &cobra.Command{
 		Use:     "promotions --project=project [--stage=stage] [NAME...]",
 		Aliases: []string{"promotion", "promos", "promo"},
@@ -54,11 +52,11 @@ kargo get promotions --project=my-project some-promotion
 			req := &v1alpha1.ListPromotionsRequest{
 				Project: project,
 			}
-			if stage, ok := flag.Stage.Get(); ok {
+			if stage != "" {
 				req.Stage = proto.String(stage)
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -93,8 +91,8 @@ kargo get promotions --project=my-project some-promotion
 			return resErr
 		},
 	}
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
-	option.OptionalStage(flag.Stage)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
+	option.Stage(cmd.Flags(), &stage)
 	opt.PrintFlags.AddFlags(cmd)
 	return cmd
 }
diff --git a/internal/cli/cmd/get/stages.go b/internal/cli/cmd/get/stages.go
index be7d8aa0..549f486b 100644
--- a/internal/cli/cmd/get/stages.go
+++ b/internal/cli/cmd/get/stages.go
@@ -14,11 +14,15 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newGetStagesCommand(opt *option.Option) *cobra.Command {
+func newGetStagesCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "stages --project=project [NAME...]",
 		Aliases: []string{"stage"},
@@ -41,7 +45,7 @@ kargo get stages --project=my-project my-stage
 				return errors.New("project is required")
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -78,7 +82,7 @@ kargo get stages --project=my-project my-stage
 			return resErr
 		},
 	}
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	opt.PrintFlags.AddFlags(cmd)
 	return cmd
 }
diff --git a/internal/cli/cmd/get/warehouse.go b/internal/cli/cmd/get/warehouse.go
index fb9e268e..f47cb189 100644
--- a/internal/cli/cmd/get/warehouse.go
+++ b/internal/cli/cmd/get/warehouse.go
@@ -11,11 +11,15 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newGetWarehousesCommand(opt *option.Option) *cobra.Command {
+func newGetWarehousesCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "warehouses --project=project [NAME...]",
 		Aliases: []string{"warehouse"},
@@ -38,7 +42,7 @@ kargo get warehouses --project=my-project my-warehouse
 				return errors.New("project is required")
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -83,7 +87,7 @@ kargo get warehouses --project=my-project my-warehouse
 			return resErr
 		},
 	}
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	opt.PrintFlags.AddFlags(cmd)
 	return cmd
 }
diff --git a/internal/cli/cmd/login/login.go b/internal/cli/cmd/login/login.go
index b08c3431..6f0a2ebe 100644
--- a/internal/cli/cmd/login/login.go
+++ b/internal/cli/cmd/login/login.go
@@ -31,19 +31,15 @@ import (
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-const (
-	flagAdmin                = "admin"
-	flagKubeconfig           = "kubeconfig"
-	flagPassword             = "password"
-	flagPort                 = "port"
-	flagSSO                  = "sso"
-	defaultRandStringCharSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
-)
+const defaultRandStringCharSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
 
 //go:embed assets
 var assets embed.FS
 
 func NewCommand(opt *option.Option) *cobra.Command {
+	var useAdmin, useKubeconfig, useSSO bool
+	var password string
+	var callbackPort int
 	cmd := &cobra.Command{
 		Use:     "login server-address",
 		Args:    option.ExactArgs(1),
@@ -52,21 +48,6 @@ func NewCommand(opt *option.Option) *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
 
-			useAdmin, err := cmd.Flags().GetBool(flagAdmin)
-			if err != nil {
-				return err
-			}
-
-			useKubeconfig, err := cmd.Flags().GetBool(flagKubeconfig)
-			if err != nil {
-				return err
-			}
-
-			useSSO, err := cmd.Flags().GetBool(flagSSO)
-			if err != nil {
-				return err
-			}
-
 			var flagCount int
 			if useAdmin {
 				flagCount++
@@ -85,12 +66,8 @@ func NewCommand(opt *option.Option) *cobra.Command {
 
 			serverAddress := args[0]
 			var bearerToken, refreshToken string
+			var err error
 			if useAdmin {
-				var password string
-				if password, err = cmd.Flags().GetString(flagPassword); err != nil {
-					return err
-				}
-
 				for {
 					if password != "" {
 						break
@@ -102,23 +79,15 @@ func NewCommand(opt *option.Option) *cobra.Command {
 						return err
 					}
 				}
-
 				if bearerToken, err =
 					adminLogin(ctx, serverAddress, password, opt.InsecureTLS); err != nil {
 					return err
 				}
 			} else if useKubeconfig {
-				bearerToken, err = kubeconfigLogin(ctx)
-				if err != nil {
+				if bearerToken, err = kubeconfigLogin(ctx); err != nil {
 					return err
 				}
 			} else {
-				var callbackPort int
-				callbackPort, err = cmd.Flags().GetInt(flagPort)
-				if err != nil {
-					return err
-				}
-
 				if bearerToken, refreshToken, err =
 					ssoLogin(ctx, serverAddress, callbackPort, opt.InsecureTLS); err != nil {
 					return err
@@ -145,41 +114,47 @@ func NewCommand(opt *option.Option) *cobra.Command {
 			return errors.Wrap(err, "error persisting configuration")
 		},
 	}
-	cmd.Flags().BoolP(
-		flagAdmin,
+	cmd.Flags().BoolVarP(
+		&useAdmin,
+		"admin",
 		"a",
 		false,
 		"Log in as the Kargo admin user; mutually exclusive with --kubeconfig and "+
 			"--sso",
 	)
-	cmd.Flags().BoolP(
-		flagKubeconfig,
+	cmd.Flags().BoolVarP(
+		&useKubeconfig,
+		"kubeconfig",
 		"k",
 		false,
 		"Log in using a token obtained from the local Kubernetes configuration's "+
 			"current context; mutually exclusive with --admin and --sso",
 	)
-	cmd.Flags().StringP(
-		flagPassword,
+	cmd.Flags().StringVarP(
+		&password,
+		"password",
 		"P",
 		"",
 		"Specify the password for non-interactive admin user login; only used "+
 			"with --admin",
 	)
-	cmd.Flags().IntP(
-		flagPort,
+	cmd.Flags().IntVarP(
+		&callbackPort,
+		"port",
 		"p",
 		0,
 		"Port to use for the callback URL; 0 selects any available, "+
 			"unprivileged port; only used with --sso",
 	)
-	cmd.Flags().BoolP(
-		flagSSO,
+	cmd.Flags().BoolVarP(
+		&useSSO,
+		"sso",
 		"s",
 		false,
 		"Log in using OpenID Connect and the server's configured identity "+
 			"provider; mutually exclusive with --admin and --kubeconfig",
 	)
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
 	return cmd
 }
 
diff --git a/internal/cli/cmd/refresh/refresh.go b/internal/cli/cmd/refresh/refresh.go
index c181d5ed..906a69b8 100644
--- a/internal/cli/cmd/refresh/refresh.go
+++ b/internal/cli/cmd/refresh/refresh.go
@@ -9,22 +9,22 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func NewCommand(opt *option.Option) *cobra.Command {
+func NewCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "refresh",
 		Short: "Refresh a stage or warehouse",
 	}
-	cmd.AddCommand(newRefreshWarehouseCommand(opt))
-	cmd.AddCommand(newRefreshStageCommand(opt))
-	return cmd
-}
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
+	option.LocalServer(cmd.PersistentFlags(), opt)
 
-type Flags struct {
-	Wait bool
+	cmd.AddCommand(newRefreshWarehouseCommand(cfg, opt))
+	cmd.AddCommand(newRefreshStageCommand(cfg, opt))
+	return cmd
 }
 
 const (
@@ -32,18 +32,15 @@ const (
 	refreshResourceTypeStage     = "stage"
 )
 
-func addRefreshFlags(cmd *cobra.Command, flag *Flags) {
-	cmd.Flags().BoolVar(&flag.Wait, "wait", true, "Wait until refresh completes")
-}
-
 func refreshObject(
+	cfg config.CLIConfig,
 	opt *option.Option,
-	flag *Flags,
 	resourceType string,
+	wait bool,
 ) func(cmd *cobra.Command, args []string) error {
 	return func(cmd *cobra.Command, args []string) error {
 		ctx := cmd.Context()
-		kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+		kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 		if err != nil {
 			return err
 		}
@@ -74,7 +71,7 @@ func refreshObject(
 			return errors.Wrapf(err, "refresh %s", resourceType)
 		}
 
-		if flag.Wait {
+		if wait {
 			switch resourceType {
 			case refreshResourceTypeWarehouse:
 				err = waitForWarehouse(ctx, kargoSvcCli, project, name)
diff --git a/internal/cli/cmd/refresh/stage.go b/internal/cli/cmd/refresh/stage.go
index d7567956..1c81e921 100644
--- a/internal/cli/cmd/refresh/stage.go
+++ b/internal/cli/cmd/refresh/stage.go
@@ -8,21 +8,25 @@ import (
 	"github.com/spf13/cobra"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 	"github.com/akuity/kargo/pkg/api/service/v1alpha1/svcv1alpha1connect"
 )
 
-func newRefreshStageCommand(opt *option.Option) *cobra.Command {
-	var flag Flags
+func newRefreshStageCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
+	var wait bool
 	cmd := &cobra.Command{
 		Use:     "stage (STAGE)",
 		Args:    option.ExactArgs(1),
 		Example: "kargo refresh stage --project=guestbook (STAGE)",
-		RunE:    refreshObject(opt, &flag, "stage"),
+		RunE:    refreshObject(cfg, opt, "stage", wait),
 	}
-	addRefreshFlags(cmd, &flag)
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Wait(cmd.Flags(), &wait)
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
 
diff --git a/internal/cli/cmd/refresh/warehouse.go b/internal/cli/cmd/refresh/warehouse.go
index 5411ea44..5d0a1540 100644
--- a/internal/cli/cmd/refresh/warehouse.go
+++ b/internal/cli/cmd/refresh/warehouse.go
@@ -8,21 +8,25 @@ import (
 	"github.com/spf13/cobra"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 	"github.com/akuity/kargo/pkg/api/service/v1alpha1/svcv1alpha1connect"
 )
 
-func newRefreshWarehouseCommand(opt *option.Option) *cobra.Command {
-	var flag Flags
+func newRefreshWarehouseCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
+	var wait bool
 	cmd := &cobra.Command{
 		Use:     "warehouse (WAREHOUSE)",
 		Args:    option.ExactArgs(1),
 		Example: "kargo warehouse refresh --project=guestbook (WAREHOUSE)",
-		RunE:    refreshObject(opt, &flag, "warehouse"),
+		RunE:    refreshObject(cfg, opt, "warehouse", wait),
 	}
-	addRefreshFlags(cmd, &flag)
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Wait(cmd.Flags(), &wait)
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
 
diff --git a/internal/cli/cmd/stage/promote.go b/internal/cli/cmd/stage/promote.go
index 666a4f3f..35c230fe 100644
--- a/internal/cli/cmd/stage/promote.go
+++ b/internal/cli/cmd/stage/promote.go
@@ -11,16 +11,16 @@ import (
 
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-type PromoteFlags struct {
-	Freight string
-}
-
-func newPromoteCommand(opt *option.Option) *cobra.Command {
-	var flag PromoteFlags
+func newPromoteCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
+	var freight string
 	cmd := &cobra.Command{
 		Use:  "promote --project=project (STAGE) [(--freight=)freight-id]",
 		Args: option.ExactArgs(1),
@@ -40,7 +40,7 @@ kargo stage promote dev --freight=abc123
 				return errors.New("project is required")
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return err
 			}
@@ -50,7 +50,6 @@ kargo stage promote dev --freight=abc123
 				return errors.New("name is required")
 			}
 
-			freight := strings.TrimSpace(flag.Freight)
 			if freight == "" {
 				// TODO: Get latest available freight if empty
 				return errors.New("freight is required")
@@ -79,7 +78,7 @@ kargo stage promote dev --freight=abc123
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Freight(&flag.Freight)(cmd.Flags())
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Freight(cmd.Flags(), &freight)
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
diff --git a/internal/cli/cmd/stage/promote_subscribers.go b/internal/cli/cmd/stage/promote_subscribers.go
index 93e987c2..ba550dc1 100644
--- a/internal/cli/cmd/stage/promote_subscribers.go
+++ b/internal/cli/cmd/stage/promote_subscribers.go
@@ -11,16 +11,16 @@ import (
 
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-type PromoteSubscribersFlags struct {
-	Freight string
-}
-
-func newPromoteSubscribersCommand(opt *option.Option) *cobra.Command {
-	var flag PromoteSubscribersFlags
+func newPromoteSubscribersCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
+	var freight string
 	cmd := &cobra.Command{
 		Use:  "promote-subscribers --project=project (STAGE) [(--freight=)freight-id]",
 		Args: option.ExactArgs(1),
@@ -40,7 +40,7 @@ kargo stage promote-subscribers dev --freight=abc123
 				return errors.New("project is required")
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return err
 			}
@@ -50,7 +50,6 @@ kargo stage promote-subscribers dev --freight=abc123
 				return errors.New("name is required")
 			}
 
-			freight := strings.TrimSpace(flag.Freight)
 			if freight == "" {
 				return errors.New("freight is required")
 			}
@@ -84,7 +83,7 @@ kargo stage promote-subscribers dev --freight=abc123
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Freight(&flag.Freight)(cmd.Flags())
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Freight(cmd.Flags(), &freight)
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
diff --git a/internal/cli/cmd/stage/set_auto_promotion.go b/internal/cli/cmd/stage/set_auto_promotion.go
index 396f3f0c..39189d7a 100644
--- a/internal/cli/cmd/stage/set_auto_promotion.go
+++ b/internal/cli/cmd/stage/set_auto_promotion.go
@@ -12,11 +12,15 @@ import (
 
 	typesv1alpha1 "github.com/akuity/kargo/internal/api/types/v1alpha1"
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newEnableAutoPromotion(opt *option.Option) *cobra.Command {
+func newEnableAutoPromotion(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:  "enable-auto-promotion --project=project (STAGE)",
 		Args: option.ExactArgs(1),
@@ -40,15 +44,18 @@ kargo stage enable-auto-promotion dev
 			if stage == "" {
 				return errors.New("stage is required")
 			}
-			return setAutoPromotionForStage(ctx, opt, project, stage, true)
+			return setAutoPromotionForStage(ctx, cfg, opt, project, stage, true)
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
 
-func newDisableAutoPromotion(opt *option.Option) *cobra.Command {
+func newDisableAutoPromotion(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:  "disable-auto-promotion --project=project (STAGE)",
 		Args: option.ExactArgs(1),
@@ -72,16 +79,23 @@ kargo stage disable-auto-promotion dev
 			if stage == "" {
 				return errors.New("stage is required")
 			}
-			return setAutoPromotionForStage(ctx, opt, project, stage, false)
+			return setAutoPromotionForStage(ctx, cfg, opt, project, stage, false)
 		},
 	}
 	opt.PrintFlags.AddFlags(cmd)
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
 
-func setAutoPromotionForStage(ctx context.Context, opt *option.Option, project, stage string, enable bool) error {
-	kargoClient, err := client.GetClientFromConfig(ctx, opt)
+func setAutoPromotionForStage(
+	ctx context.Context,
+	cfg config.CLIConfig,
+	opt *option.Option,
+	project string,
+	stage string,
+	enable bool,
+) error {
+	kargoClient, err := client.GetClientFromConfig(ctx, cfg, opt)
 	if err != nil {
 		return err
 	}
diff --git a/internal/cli/cmd/stage/stage.go b/internal/cli/cmd/stage/stage.go
index b9676506..85ce3c00 100644
--- a/internal/cli/cmd/stage/stage.go
+++ b/internal/cli/cmd/stage/stage.go
@@ -3,17 +3,21 @@ package stage
 import (
 	"github.com/spf13/cobra"
 
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 )
 
-func NewCommand(opt *option.Option) *cobra.Command {
+func NewCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "stage",
 		Short: "Manage stages",
 	}
-	cmd.AddCommand(newPromoteCommand(opt))
-	cmd.AddCommand(newEnableAutoPromotion(opt))
-	cmd.AddCommand(newDisableAutoPromotion(opt))
-	cmd.AddCommand(newPromoteSubscribersCommand(opt))
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
+	option.LocalServer(cmd.PersistentFlags(), opt)
+
+	cmd.AddCommand(newPromoteCommand(cfg, opt))
+	cmd.AddCommand(newEnableAutoPromotion(cfg, opt))
+	cmd.AddCommand(newDisableAutoPromotion(cfg, opt))
+	cmd.AddCommand(newPromoteSubscribersCommand(cfg, opt))
 	return cmd
 }
diff --git a/internal/cli/cmd/update/freight-alias.go b/internal/cli/cmd/update/freight-alias.go
index 8ee5249a..ea1c896b 100644
--- a/internal/cli/cmd/update/freight-alias.go
+++ b/internal/cli/cmd/update/freight-alias.go
@@ -6,11 +6,15 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/akuity/kargo/internal/cli/client"
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 	v1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
-func newUpdateFreightAliasCommand(opt *option.Option) *cobra.Command {
+func newUpdateFreightAliasCommand(
+	cfg config.CLIConfig,
+	opt *option.Option,
+) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "freight-alias --project=project NAME ALIAS",
 		Args:    option.ExactArgs(2),
@@ -24,7 +28,7 @@ func newUpdateFreightAliasCommand(opt *option.Option) *cobra.Command {
 				return errors.New("project is required")
 			}
 
-			kargoSvcCli, err := client.GetClientFromConfig(ctx, opt)
+			kargoSvcCli, err := client.GetClientFromConfig(ctx, cfg, opt)
 			if err != nil {
 				return errors.Wrap(err, "get client from config")
 			}
@@ -45,6 +49,6 @@ func newUpdateFreightAliasCommand(opt *option.Option) *cobra.Command {
 			return nil
 		},
 	}
-	option.Project(&opt.Project, opt.Project)(cmd.Flags())
+	option.Project(cmd.Flags(), opt, opt.Project)
 	return cmd
 }
diff --git a/internal/cli/cmd/update/update.go b/internal/cli/cmd/update/update.go
index 78b2d7f3..665f84f6 100644
--- a/internal/cli/cmd/update/update.go
+++ b/internal/cli/cmd/update/update.go
@@ -3,14 +3,18 @@ package update
 import (
 	"github.com/spf13/cobra"
 
+	"github.com/akuity/kargo/internal/cli/config"
 	"github.com/akuity/kargo/internal/cli/option"
 )
 
-func NewCommand(opt *option.Option) *cobra.Command {
+func NewCommand(cfg config.CLIConfig, opt *option.Option) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "update",
 		Short: "Update a freight alias",
 	}
-	cmd.AddCommand(newUpdateFreightAliasCommand(opt))
+	option.InsecureTLS(cmd.PersistentFlags(), opt)
+	option.LocalServer(cmd.PersistentFlags(), opt)
+
+	cmd.AddCommand(newUpdateFreightAliasCommand(cfg, opt))
 	return cmd
 }
diff --git a/internal/cli/option/flag.go b/internal/cli/option/flag.go
index 28aa9242..be02e577 100644
--- a/internal/cli/option/flag.go
+++ b/internal/cli/option/flag.go
@@ -6,41 +6,31 @@ import (
 	"github.com/spf13/pflag"
 )
 
-type FlagFn func(*pflag.FlagSet)
+func Filenames(fs *pflag.FlagSet, filenames *[]string, verb string) {
+	fs.StringSliceVarP(filenames, "filename", "f", nil,
+		fmt.Sprintf("Filename, directory, or URL to files to use to %s the resource", verb))
+}
 
-func Filenames(verb string, v *[]string) FlagFn {
-	return func(fs *pflag.FlagSet) {
-		fs.StringSliceVarP(v, "filename", "f", nil,
-			fmt.Sprintf("Filename, directory, or URL to files to use to %s the resource", verb))
-	}
+func InsecureTLS(fs *pflag.FlagSet, opt *Option) {
+	fs.BoolVar(&opt.InsecureTLS, "insecure-skip-tls-verify", false, "Skip TLS certificate verification")
 }
 
-func InsecureTLS(v *bool) FlagFn {
-	return func(fs *pflag.FlagSet) {
-		fs.BoolVar(v, "insecure-skip-tls-verify", false, "Skip TLS certificate verification")
-	}
+func LocalServer(fs *pflag.FlagSet, opt *Option) {
+	fs.BoolVar(&opt.UseLocalServer, "local-server", false, "Use local server")
 }
 
-func LocalServer(v *bool) FlagFn {
-	return func(fs *pflag.FlagSet) {
-		fs.BoolVar(v, "local-server", false, "Use local server")
-	}
+func Project(fs *pflag.FlagSet, opt *Option, defaultProject string) {
+	fs.StringVarP(&opt.Project, "project", "p", defaultProject, "Project")
 }
 
-func Project(v *string, defaultProject string) FlagFn {
-	return func(s *pflag.FlagSet) {
-		s.StringVarP(v, "project", "p", defaultProject, "Project")
-	}
+func Stage(fs *pflag.FlagSet, stage *string) {
+	fs.StringVar(stage, "stage", "", "Stage")
 }
 
-func OptionalStage(v Optional[string]) FlagFn {
-	return func(fs *pflag.FlagSet) {
-		fs.Var(v, "stage", "Stage")
-	}
+func Freight(fs *pflag.FlagSet, freight *string) {
+	fs.StringVar(freight, "freight", "", "Freight ID")
 }
 
-func Freight(v *string) FlagFn {
-	return func(fs *pflag.FlagSet) {
-		fs.StringVar(v, "freight", "", "Freight ID")
-	}
+func Wait(fs *pflag.FlagSet, wait *bool) {
+	fs.BoolVar(wait, "wait", false, "Wait until refresh completes")
 }
diff --git a/internal/cli/option/types.go b/internal/cli/option/types.go
deleted file mode 100644
index 8989c459..00000000
--- a/internal/cli/option/types.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package option
-
-import (
-	"strconv"
-
-	"github.com/pkg/errors"
-	"github.com/samber/mo"
-	"github.com/spf13/pflag"
-)
-
-type Optional[T any] interface {
-	pflag.Value
-
-	/* partial methods of mo.Option */
-
-	IsPresent() bool
-	Get() (T, bool)
-	OrElse(fallback T) T
-}
-
-type optionalBool struct {
-	mo.Option[bool]
-}
-
-func (o *optionalBool) String() string {
-	v, ok := o.Get()
-	if !ok {
-		return ""
-	}
-	return strconv.FormatBool(v)
-}
-
-func (o *optionalBool) Set(value string) error {
-	v, err := strconv.ParseBool(value)
-	if err != nil {
-		return errors.Wrap(err, "parse bool")
-	}
-	o.Option = mo.Some[bool](v)
-	return nil
-}
-
-func (o *optionalBool) Type() string {
-	return "bool"
-}
-
-func OptionalBool() Optional[bool] {
-	return &optionalBool{
-		Option: mo.None[bool](),
-	}
-}
-
-type optionalString struct {
-	mo.Option[string]
-}
-
-func (o *optionalString) String() string {
-	v, ok := o.Get()
-	if !ok {
-		return ""
-	}
-	return v
-}
-
-func (o *optionalString) Set(value string) error {
-	o.Option = mo.Some[string](value)
-	return nil
-}
-
-func (o *optionalString) Type() string {
-	return "string"
-}
-
-func OptionalString() Optional[string] {
-	return &optionalString{
-		Option: mo.None[string](),
-	}
-}
diff --git a/internal/controller/management/projects/projects.go b/internal/controller/management/projects/projects.go
new file mode 100644
index 00000000..5a1a1c1e
--- /dev/null
+++ b/internal/controller/management/projects/projects.go
@@ -0,0 +1,41 @@
+package projects
+
+import (
+	"context"
+
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/manager"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/controller"
+)
+
+// reconciler reconciles Project resources.
+type reconciler struct {
+	kargoClient client.Client
+}
+
+// SetupReconcilerWithManager initializes a reconciler for Project resources and
+// registers it with the provided Manager.
+func SetupReconcilerWithManager(kargoMgr manager.Manager) error {
+	return ctrl.NewControllerManagedBy(kargoMgr).
+		For(&kargoapi.Project{}).
+		WithOptions(controller.CommonOptions()).
+		Complete(newReconciler(kargoMgr.GetClient()))
+}
+
+func newReconciler(kargoClient client.Client) *reconciler {
+	return &reconciler{
+		kargoClient: kargoClient,
+	}
+}
+
+// Reconcile is part of the main Kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+func (r *reconciler) Reconcile(
+	context.Context,
+	ctrl.Request,
+) (ctrl.Result, error) {
+	return ctrl.Result{}, nil
+}
diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go
index 8690fd28..5def6788 100644
--- a/internal/credentials/credentials.go
+++ b/internal/credentials/credentials.go
@@ -68,56 +68,39 @@ type Database interface {
 }
 
 // kubernetesDatabase is an implementation of the Database interface that
-// utilizes a Kubernetes controller runtime client to index and retrieve
-// credentials stored in Kubernetes Secrets.
+// utilizes a Kubernetes controller runtime client to retrieve credentials
+// stored in Kubernetes Secrets.
 type kubernetesDatabase struct {
-	*kubernetesDatabaseConfig
+	kargoClient client.Client
+	argoClient  client.Client // nil if credential borrowing is not enabled
+	cfg         KubernetesDatabaseConfig
 }
 
-// kubernetesDatabaseConfig is a configuration struct for the
-// kubernetesDatabase struct. It uses the functional options pattern to allow
-// for easy configuration.
-type kubernetesDatabaseConfig struct {
+// KubernetesDatabaseConfig represents configuration for a Kubernetes based
+// implementation of the Database interface.
+type KubernetesDatabaseConfig struct {
 	ArgoCDNamespace             string   `envconfig:"ARGOCD_NAMESPACE" default:"argocd"`
 	GlobalCredentialsNamespaces []string `envconfig:"GLOBAL_CREDENTIALS_NAMESPACES" default:""`
-
-	kargoClient client.Client
-	// it set we know that "borrowing ArgoCD creds" is enabled
-
-	argoClient client.Client
 }
 
-// KubernetesDatabaseOption is a functional option for configuring a
-// kubernetesDatabase. Options defined in credentials_options.go.
-type KubernetesDatabaseOption func(*kubernetesDatabaseConfig)
-
-func createConfig(opts ...KubernetesDatabaseOption) *kubernetesDatabaseConfig {
-	config := &kubernetesDatabaseConfig{}
-
-	// load from env if defined
-	envconfig.MustProcess("", config)
-
-	// apply user supplied options
-	for _, opt := range opts {
-		opt(config)
-	}
-
-	return config
+func KubernetesDatabaseConfigFromEnv() KubernetesDatabaseConfig {
+	cfg := KubernetesDatabaseConfig{}
+	envconfig.MustProcess("", &cfg)
+	return cfg
 }
 
 // NewKubernetesDatabase initializes and returns an implementation of the
 // Database interface that utilizes a Kubernetes controller runtime client to
-// index and retrieve Credentials stored in Kubernetes Secrets. This function
-// carries out the important task of indexing Credentials stored in Kubernetes
-// Secrets by repository type + URL.
+// retrieve Credentials stored in Kubernetes Secrets.
 func NewKubernetesDatabase(
 	kargoClient client.Client,
-	opts ...KubernetesDatabaseOption,
+	argoClient client.Client,
+	cfg KubernetesDatabaseConfig,
 ) Database {
-	cfg := createConfig(opts...)
-	cfg.kargoClient = kargoClient
 	return &kubernetesDatabase{
-		kubernetesDatabaseConfig: cfg,
+		kargoClient: kargoClient,
+		argoClient:  argoClient,
+		cfg:         cfg,
 	}
 }
 
@@ -172,7 +155,7 @@ func (k *kubernetesDatabase) Get(
 	}
 
 	// Check global credentials namespaces for credentials
-	for _, globalCredsNamespace := range k.GlobalCredentialsNamespaces {
+	for _, globalCredsNamespace := range k.cfg.GlobalCredentialsNamespaces {
 		// Check shared creds namespace for credentials
 		if secret, err = getCredentialsSecret(
 			ctx,
@@ -221,7 +204,7 @@ func (k *kubernetesDatabase) Get(
 	if secret, err = getCredentialsSecret(
 		ctx,
 		k.argoClient,
-		k.ArgoCDNamespace,
+		k.cfg.ArgoCDNamespace,
 		labels.Set(map[string]string{
 			argoCDSecretTypeLabelKey: repositorySecretTypeLabelValue,
 		}).AsSelector(),
@@ -237,7 +220,7 @@ func (k *kubernetesDatabase) Get(
 		if secret, err = getCredentialsSecret(
 			ctx,
 			k.argoClient,
-			k.ArgoCDNamespace,
+			k.cfg.ArgoCDNamespace,
 			labels.Set(map[string]string{
 				argoCDSecretTypeLabelKey: repoCredsSecretTypeLabelValue,
 			}).AsSelector(),
diff --git a/internal/credentials/credentials_options.go b/internal/credentials/credentials_options.go
deleted file mode 100644
index 9ff2f98d..00000000
--- a/internal/credentials/credentials_options.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package credentials
-
-import "sigs.k8s.io/controller-runtime/pkg/client"
-
-func WithArgoCDNamespace(namespace string) KubernetesDatabaseOption {
-	return func(config *kubernetesDatabaseConfig) {
-		config.ArgoCDNamespace = namespace
-	}
-}
-
-func WithGlobalCredentialsNamespaces(namespaces []string) KubernetesDatabaseOption {
-	return func(config *kubernetesDatabaseConfig) {
-		config.GlobalCredentialsNamespaces = namespaces
-	}
-}
-
-func WithArgoClient(argoClient client.Client) KubernetesDatabaseOption {
-	return func(config *kubernetesDatabaseConfig) {
-		config.argoClient = argoClient
-	}
-}
diff --git a/internal/image/repository_client.go b/internal/image/repository_client.go
index dc4df232..21094eb2 100644
--- a/internal/image/repository_client.go
+++ b/internal/image/repository_client.go
@@ -28,12 +28,16 @@ import (
 	"github.com/akuity/kargo/internal/logging"
 )
 
-// maxMetadataConcurrency is the maximum number of concurrent goroutines that
-// can be used to fetch metadata. Per Go's documentation, goroutines are very
-// cheap (practical to spawn tens of thousands), AND we have a rate limiter in
-// place for each registry, so there's no reason for this number not to be
-// pretty large.
-const maxMetadataConcurrency = 1000
+const (
+	// maxMetadataConcurrency is the maximum number of concurrent goroutines that
+	// can be used to fetch metadata. Per Go's documentation, goroutines are very
+	// cheap (practical to spawn tens of thousands), AND we have a rate limiter in
+	// place for each registry, so there's no reason for this number not to be
+	// pretty large.
+	maxMetadataConcurrency = 1000
+
+	unknown = "unknown"
+)
 
 var metaSem = semaphore.NewWeighted(maxMetadataConcurrency)
 
@@ -533,6 +537,12 @@ func (r *repositoryClient) extractTagFromOCIManifest(
 		)
 	}
 
+	if info.OS == unknown || info.OS == "" || info.Arch == unknown || info.Arch == "" {
+		// This doesn't look like an image. It might be an attestation or something
+		// else. It's definitely not what we're looking for.
+		return nil, nil
+	}
+
 	if platform != nil &&
 		!platform.matches(info.OS, info.Arch, info.Variant) {
 		return nil, nil
@@ -581,8 +591,8 @@ func (r *repositoryClient) extractTagFromCollection(
 	refs := make([]distribution.Descriptor, 0, len(collection.References()))
 	for _, ref := range collection.References() {
 		if ref.Platform == nil ||
-			ref.Platform.OS == "unknown" || ref.Platform.OS == "" ||
-			ref.Platform.Architecture == "unknown" || ref.Platform.Architecture == "" {
+			ref.Platform.OS == unknown || ref.Platform.OS == "" ||
+			ref.Platform.Architecture == unknown || ref.Platform.Architecture == "" {
 			// This reference doesn't look like a reference to an image. It might
 			// be an attestation or something else. Skip it.
 			continue
diff --git a/ui/src/app.tsx b/ui/src/app.tsx
index b7d20055..29ffd73d 100644
--- a/ui/src/app.tsx
+++ b/ui/src/app.tsx
@@ -4,7 +4,6 @@ import { ConfigProvider } from 'antd';
 import { BrowserRouter, Route, Routes } from 'react-router-dom';
 
 import { transport } from '@ui/config/transport';
-import { ModalContextProvider } from '@ui/features/common/modal/modal-context';
 import { Project } from '@ui/pages/project';
 
 import { paths } from './config/paths';
@@ -24,23 +23,21 @@ export const App = () => (
   <TransportProvider transport={transport}>
     <QueryClientProvider client={queryClient}>
       <ConfigProvider theme={themeConfig}>
-        <ModalContextProvider>
-          <AuthContextProvider>
-            <BrowserRouter>
-              <Routes>
-                <Route element={<ProtectedRoute />}>
-                  <Route element={<MainLayout />}>
-                    <Route path={paths.projects} element={<Projects />} />
-                    <Route path={paths.project} element={<Project />} />
-                    <Route path={paths.stage} element={<Project />} />
-                  </Route>
+        <AuthContextProvider>
+          <BrowserRouter>
+            <Routes>
+              <Route element={<ProtectedRoute />}>
+                <Route element={<MainLayout />}>
+                  <Route path={paths.projects} element={<Projects />} />
+                  <Route path={paths.project} element={<Project />} />
+                  <Route path={paths.stage} element={<Project />} />
                 </Route>
-                <Route path={paths.login} element={<Login />} />
-                <Route path={paths.tokenRenew} element={<TokenRenew />} />
-              </Routes>
-            </BrowserRouter>
-          </AuthContextProvider>
-        </ModalContextProvider>
+              </Route>
+              <Route path={paths.login} element={<Login />} />
+              <Route path={paths.tokenRenew} element={<TokenRenew />} />
+            </Routes>
+          </BrowserRouter>
+        </AuthContextProvider>
       </ConfigProvider>
     </QueryClientProvider>
   </TransportProvider>
diff --git a/ui/src/features/auth/protected-route.tsx b/ui/src/features/auth/protected-route.tsx
index 38944ab0..3c9a2032 100644
--- a/ui/src/features/auth/protected-route.tsx
+++ b/ui/src/features/auth/protected-route.tsx
@@ -1,9 +1,12 @@
 import { TransportProvider } from '@bufbuild/connect-query';
+import { useEffect, useRef, useState } from 'react';
 import { Navigate, Outlet } from 'react-router-dom';
 
 import { paths } from '@ui/config/paths';
 import { transportWithAuth } from '@ui/config/transport';
 
+import { ModalContextProvider } from '../common/modal/modal-context';
+
 import { useAuthContext } from './context/use-auth-context';
 
 export const ProtectedRoute = () => {
@@ -13,9 +16,21 @@ export const ProtectedRoute = () => {
     return <Navigate to={paths.login} replace />;
   }
 
+  const modalRef = useRef<HTMLDivElement>(null);
+  const [modalRoot, setModalRoot] = useState<HTMLDivElement | null>(null);
+  useEffect(() => {
+    setModalRoot(modalRef.current);
+  }, []);
+
   return (
     <TransportProvider transport={transportWithAuth}>
-      <Outlet />
+      <div ref={modalRef}>
+        {modalRoot && (
+          <ModalContextProvider container={modalRoot}>
+            <Outlet />
+          </ModalContextProvider>
+        )}
+      </div>
     </TransportProvider>
   );
 };
diff --git a/ui/src/features/common/freight-label.tsx b/ui/src/features/common/freight-label.tsx
index ae43982a..2d91faa5 100644
--- a/ui/src/features/common/freight-label.tsx
+++ b/ui/src/features/common/freight-label.tsx
@@ -1,3 +1,5 @@
+import { faCheck, faClipboard } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Tooltip } from 'antd';
 import { formatDistance } from 'date-fns';
 import { useEffect, useState } from 'react';
@@ -6,13 +8,21 @@ import { Freight } from '@ui/gen/v1alpha1/types_pb';
 
 const ALIAS_LABEL_KEY = 'kargo.akuity.io/alias';
 
-export const getAlias = (freight: Freight): string | undefined => {
-  return freight?.metadata?.labels[ALIAS_LABEL_KEY];
+export const getAlias = (freight?: Freight): string | undefined => {
+  return freight?.metadata?.labels[ALIAS_LABEL_KEY] || undefined;
 };
 
 export const FreightLabel = ({ freight }: { freight?: Freight }) => {
   const [id, setId] = useState<string>('');
   const [alias, setAlias] = useState<string | undefined>();
+  const [copied, setCopied] = useState<boolean>(false);
+
+  useEffect(() => {
+    if (copied) {
+      const timeout = setTimeout(() => setCopied(false), 1000);
+      return () => clearTimeout(timeout);
+    }
+  }, [copied]);
 
   useEffect(() => {
     setAlias(freight?.metadata?.labels[ALIAS_LABEL_KEY]);
@@ -20,27 +30,45 @@ export const FreightLabel = ({ freight }: { freight?: Freight }) => {
   }, [freight]);
 
   return (
-    <span className='truncate'>
+    <div
+      className='truncate cursor-pointer'
+      onClick={() => {
+        navigator.clipboard.writeText(alias || id);
+        setCopied(true);
+      }}
+    >
       <Tooltip
         title={
           <>
-            {alias && <div>Alias: {alias}</div>}
-            <div>ID: {id}</div>
-            <div>
-              {freight?.metadata?.creationTimestamp && (
-                <>
-                  Created:{' '}
+            <div className='uppercase text-xs w-full text-center font-semibold text-gray-400'>
+              <FontAwesomeIcon icon={copied ? faCheck : faClipboard} className='mr-2' />
+              {copied ? 'Copied' : `Click to copy ${alias ? 'alias' : 'id'}`}
+            </div>
+            {alias && <Info title='Alias'>{alias}</Info>}
+            <Info title='ID'>
+              <div className='font-mono'>{id}</div>
+            </Info>
+            {freight?.metadata?.creationTimestamp && (
+              <Info title='Created'>
+                <div className='text-right'>
                   {formatDistance(freight?.metadata?.creationTimestamp?.toDate(), new Date(), {
                     addSuffix: true
                   })}
-                </>
-              )}
-            </div>
+                </div>
+              </Info>
+            )}
           </>
         }
       >
-        {alias || id}
+        <div className='hover:text-gray-100'>{alias || id}</div>
       </Tooltip>
-    </span>
+    </div>
   );
 };
+
+const Info = ({ title, children }: { title: string; children: React.ReactNode }) => (
+  <div className='flex items-center my-1'>
+    <div className='text-xs text-gray-400 mr-4'>{title}</div>
+    <div className='text-sm ml-auto'>{children}</div>
+  </div>
+);
diff --git a/ui/src/features/common/modal/modal-context.tsx b/ui/src/features/common/modal/modal-context.tsx
index 6bbf50a2..48f15374 100644
--- a/ui/src/features/common/modal/modal-context.tsx
+++ b/ui/src/features/common/modal/modal-context.tsx
@@ -24,9 +24,10 @@ interface ModalContextValue {
 export const ModalContext = React.createContext<ModalContextValue | null>(null);
 interface ModalProviderProps {
   children: React.ReactNode;
+  container: HTMLElement | null;
 }
 
-export const ModalContextProvider = ({ children }: ModalProviderProps) => {
+export const ModalContextProvider = ({ children, container }: ModalProviderProps) => {
   const [modals, setModals] = React.useState<Record<string, ModalItem>>({});
 
   const show = (key: string, modal: ModalComponent) => {
@@ -74,7 +75,7 @@ export const ModalContextProvider = ({ children }: ModalProviderProps) => {
       {/* REACT SPECIFIC BUG - https://github.com/remix-run/react-router/issues/8834#issuecomment-1118083034 */}
       <div className='h-full'>
         {children}
-        <Portal>
+        <Portal container={container}>
           <>
             {Object.keys(modals).map((key) => {
               const { component: Component, visible } = modals[key];
diff --git a/ui/src/features/common/portal.tsx b/ui/src/features/common/portal.tsx
index 769e8ffb..4b756597 100644
--- a/ui/src/features/common/portal.tsx
+++ b/ui/src/features/common/portal.tsx
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
 
 type PortalProps = {
   children: React.ReactElement;
-  container?: HTMLElement;
+  container?: HTMLElement | null;
   disablePortal?: boolean;
 };
 
diff --git a/ui/src/features/project/project-details/nodes/stage-node.tsx b/ui/src/features/project/project-details/nodes/stage-node.tsx
index 6b0a876a..580dc97f 100644
--- a/ui/src/features/project/project-details/nodes/stage-node.tsx
+++ b/ui/src/features/project/project-details/nodes/stage-node.tsx
@@ -101,9 +101,9 @@ export const StageNode = ({
           ) : (
             <>
               <h3>Current Freight</h3>
-              <p className='font-mono text-sm font-semibold h-full flex items-center justify-center'>
+              <div className='font-mono text-sm font-semibold h-full flex items-center justify-center'>
                 <FreightLabel freight={currentFreight} />
-              </p>
+              </div>
             </>
           )}
         </div>
diff --git a/ui/src/features/project/project-details/project-details.tsx b/ui/src/features/project/project-details/project-details.tsx
index 761f5d2b..86d87776 100644
--- a/ui/src/features/project/project-details/project-details.tsx
+++ b/ui/src/features/project/project-details/project-details.tsx
@@ -9,6 +9,7 @@ import {
   faEllipsisV,
   faEye,
   faEyeSlash,
+  faPencil,
   faRefresh,
   faTimeline
 } from '@fortawesome/free-solid-svg-icons';
@@ -23,6 +24,7 @@ import { transportWithAuth } from '@ui/config/transport';
 import { ColorContext } from '@ui/context/colors';
 import { LoadingState } from '@ui/features/common';
 import { getAlias } from '@ui/features/common/freight-label';
+import { useModal } from '@ui/features/common/modal/use-modal';
 import { ConfirmPromotionDialogue } from '@ui/features/freightline/confirm-promotion-dialogue';
 import { FreightContents } from '@ui/features/freightline/freight-contents';
 import { FreightItem, FreightMode } from '@ui/features/freightline/freight-item';
@@ -55,6 +57,7 @@ import { RepoNode } from './nodes/repo-node';
 import { Nodule, StageNode } from './nodes/stage-node';
 import styles from './project-details.module.less';
 import { NodeType, NodesItemType } from './types';
+import { UpdateFreightAliasModal } from './update-freight-alias-modal';
 
 const lineThickness = 2;
 const nodeWidth = 150;
@@ -68,9 +71,11 @@ const getSeconds = (ts?: Timestamp): number => Number(ts?.seconds) || 0;
 export const ProjectDetails = () => {
   const { name, stageName } = useParams();
   const { data, isLoading } = useQuery(listStages.useQuery({ project: name }));
-  const { data: freightData, isLoading: isLoadingFreight } = useQuery(
-    queryFreight.useQuery({ project: name })
-  );
+  const {
+    data: freightData,
+    isLoading: isLoadingFreight,
+    refetch: refetchFreightData
+  } = useQuery(queryFreight.useQuery({ project: name }));
 
   const { data: warehouseData, isLoading: isLoadingWarehouses } = useQuery(
     listWarehouses.useQuery({ project: name })
@@ -91,6 +96,7 @@ export const ProjectDetails = () => {
     onSuccess: () => {
       message.success('Warehouse successfully refreshed');
       setPromotingStage(undefined);
+      refetchFreightData();
     }
   });
 
@@ -100,6 +106,8 @@ export const ProjectDetails = () => {
     false
   );
 
+  const { show } = useModal();
+
   React.useEffect(() => {
     if (!data || !isVisible || !warehouseData) {
       return;
@@ -141,7 +149,7 @@ export const ProjectDetails = () => {
     watchStages();
 
     const watchWarehouses = async () => {
-      const promiseClient = createPromiseClient(KargoService, transport);
+      const promiseClient = createPromiseClient(KargoService, transportWithAuth);
       const stream = promiseClient.watchWarehouses({ project: name }, { signal: cancel.signal });
       let warehouses = warehouseData?.warehouses || [];
       const refresh = {} as { [key: string]: boolean };
@@ -667,7 +675,28 @@ export const ProjectDetails = () => {
                                   message.success('Copied Freight Alias to clipboard');
                                 }
                               }
-                            : null
+                            : null,
+                          {
+                            key: '4',
+                            label: (
+                              <>
+                                <FontAwesomeIcon icon={faPencil} className='mr-2' /> Change Alias
+                              </>
+                            ),
+                            onClick: async () => {
+                              show((p) => (
+                                <UpdateFreightAliasModal
+                                  {...p}
+                                  freight={f || undefined}
+                                  project={name || ''}
+                                  onSubmit={() => {
+                                    refetchFreightData();
+                                    p.hide();
+                                  }}
+                                />
+                              ));
+                            }
+                          }
                         ]
                       }}
                     >
diff --git a/ui/src/features/project/project-details/update-freight-alias-modal.tsx b/ui/src/features/project/project-details/update-freight-alias-modal.tsx
new file mode 100644
index 00000000..be410981
--- /dev/null
+++ b/ui/src/features/project/project-details/update-freight-alias-modal.tsx
@@ -0,0 +1,75 @@
+import { faPencil } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation } from '@tanstack/react-query';
+import { Input, Modal, message } from 'antd';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { FieldContainer } from '@ui/features/common/form/field-container';
+import { getAlias } from '@ui/features/common/freight-label';
+import { ModalComponentProps } from '@ui/features/common/modal/modal-context';
+import { updateFreightAlias } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery';
+import { Freight } from '@ui/gen/v1alpha1/types_pb';
+import { zodValidators } from '@ui/utils/validators';
+
+type Props = ModalComponentProps & {
+  freight: Freight;
+  project: string;
+  onSubmit: () => void;
+};
+
+const formSchema = z.object({
+  value: zodValidators.requiredString
+});
+
+export const UpdateFreightAliasModal = ({ freight, project, onSubmit, hide, ...props }: Props) => {
+  const { control, handleSubmit } = useForm({
+    defaultValues: {
+      value: ''
+    },
+    resolver: zodResolver(formSchema)
+  });
+
+  const { mutateAsync: updateAliasAction } = useMutation({
+    ...updateFreightAlias.useMutation(),
+    onError: (err) => {
+      message.error(err?.toString());
+    },
+    onSuccess: () => {
+      message.success('Alias successfully updated');
+    }
+  });
+
+  return (
+    <Modal
+      {...props}
+      title={
+        <>
+          <FontAwesomeIcon icon={faPencil} className='mr-2' />
+          Update Alias
+        </>
+      }
+      onCancel={hide}
+      okText='Submit'
+      onOk={handleSubmit(async (data) => {
+        await updateAliasAction({
+          project,
+          freight: freight?.id || '',
+          alias: data.value || ''
+        });
+        onSubmit();
+      })}
+    >
+      <div className='mb-4'>
+        <div className='text-xs font-semibold uppercase'>Freight ID</div>
+        <div className='font-mono'>{freight?.id}</div>
+      </div>
+      <FieldContainer label='New Alias' name='value' control={control}>
+        {({ field }) => (
+          <Input {...field} type='text' placeholder={getAlias(freight) || 'New alias'} />
+        )}
+      </FieldContainer>
+    </Modal>
+  );
+};
diff --git a/ui/src/features/stage/stage-actions.tsx b/ui/src/features/stage/stage-actions.tsx
index 5b7a1a20..b1652914 100644
--- a/ui/src/features/stage/stage-actions.tsx
+++ b/ui/src/features/stage/stage-actions.tsx
@@ -50,7 +50,7 @@ export const StageActions = ({ stage }: { stage: Stage }) => {
     ) : null
   );
 
-  // Once the Refresh process is done, refetch Freigths list
+  // Once the Refresh process is done, refetch Freight list
   React.useEffect(() => {
     if (stage?.metadata?.annotations['kargo.akuity.io/refresh']) {
       setShouldRefetchFreights(true);
diff --git a/ui/src/gen/schema/projects.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/projects.kargo.akuity.io_v1alpha1.json
new file mode 100644
index 00000000..6b591d4c
--- /dev/null
+++ b/ui/src/gen/schema/projects.kargo.akuity.io_v1alpha1.json
@@ -0,0 +1,29 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "description": "Project is a resource type that reconciles to a specially labeled namespace and other TODO: TBD project-level resources.",
+  "properties": {
+    "apiVersion": {
+      "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+      "type": "string"
+    },
+    "kind": {
+      "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+      "type": "string"
+    },
+    "metadata": {
+      "type": "object"
+    },
+    "spec": {
+      "description": "Spec describes a Project.",
+      "type": "object"
+    },
+    "status": {
+      "description": "Status describes the Project's current status.",
+      "type": "object"
+    }
+  },
+  "required": [
+    "spec"
+  ],
+  "type": "object"
+}
\ No newline at end of file


Test patch:
diff --git a/internal/api/option/error_test.go b/internal/api/option/error_test.go
new file mode 100644
index 00000000..17c42c62
--- /dev/null
+++ b/internal/api/option/error_test.go
@@ -0,0 +1,120 @@
+package option
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/require"
+	kubeerr "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+
+	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
+	"github.com/akuity/kargo/pkg/api/service/v1alpha1/svcv1alpha1connect"
+)
+
+type getVersionInfoHandlerFunc func(
+	context.Context,
+	*connect.Request[svcv1alpha1.GetVersionInfoRequest],
+) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error)
+
+type testErrorInterceptorServer struct {
+	svcv1alpha1connect.UnimplementedKargoServiceHandler
+	getVersionInfoHandler getVersionInfoHandlerFunc
+}
+
+func newTestErrorInterceptorServer(
+	getVersionInfoHandler getVersionInfoHandlerFunc,
+) *testErrorInterceptorServer {
+	return &testErrorInterceptorServer{
+		getVersionInfoHandler: getVersionInfoHandler,
+	}
+}
+
+func (s *testErrorInterceptorServer) GetVersionInfo(
+	ctx context.Context,
+	req *connect.Request[svcv1alpha1.GetVersionInfoRequest],
+) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error) {
+	return s.getVersionInfoHandler(ctx, req)
+}
+
+func TestErrorInterceptor(t *testing.T) {
+	testCases := map[string]struct {
+		handlerFunc        getVersionInfoHandlerFunc
+		errExpected        bool
+		expectedStatusCode connect.Code
+	}{
+		"no error": {
+			handlerFunc: func(
+				context.Context,
+				*connect.Request[svcv1alpha1.GetVersionInfoRequest],
+			) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error) {
+				return connect.NewResponse(&svcv1alpha1.GetVersionInfoResponse{}), nil
+			},
+			errExpected: false,
+		},
+		"interceptor should not unwrap connect error with explicit status code": {
+			handlerFunc: func(
+				context.Context,
+				*connect.Request[svcv1alpha1.GetVersionInfoRequest],
+			) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error) {
+				return nil, connect.NewError(
+					connect.CodeInternal,
+					kubeerr.NewForbidden(schema.GroupResource{}, "", nil),
+				)
+			},
+			errExpected:        true,
+			expectedStatusCode: connect.CodeInternal,
+		},
+		"interceptor should not unwrap connect error with unknown status code": {
+			handlerFunc: func(
+				context.Context,
+				*connect.Request[svcv1alpha1.GetVersionInfoRequest],
+			) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error) {
+				return nil, connect.NewError(
+					connect.CodeUnknown,
+					kubeerr.NewForbidden(schema.GroupResource{}, "", nil),
+				)
+			},
+			errExpected:        true,
+			expectedStatusCode: connect.CodeUnknown,
+		},
+		"interceptor should wrap error with appropriate status code if possible": {
+			handlerFunc: func(
+				context.Context,
+				*connect.Request[svcv1alpha1.GetVersionInfoRequest],
+			) (*connect.Response[svcv1alpha1.GetVersionInfoResponse], error) {
+				return nil, kubeerr.NewForbidden(schema.GroupResource{}, "", nil)
+			},
+			errExpected:        true,
+			expectedStatusCode: connect.CodePermissionDenied,
+		},
+	}
+	for name, tc := range testCases {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			opt := connect.WithInterceptors(newErrorInterceptor())
+			mux := http.NewServeMux()
+			handler := newTestErrorInterceptorServer(tc.handlerFunc)
+			mux.Handle(svcv1alpha1connect.NewKargoServiceHandler(handler, opt))
+			srv := httptest.NewServer(mux)
+			srv.EnableHTTP2 = true
+			t.Cleanup(srv.Close)
+
+			cli := svcv1alpha1connect.NewKargoServiceClient(srv.Client(), srv.URL, connect.WithGRPC())
+			_, err := cli.GetVersionInfo(
+				context.Background(),
+				connect.NewRequest(&svcv1alpha1.GetVersionInfoRequest{}),
+			)
+			if tc.errExpected {
+				require.Error(t, err)
+				require.Equal(t, tc.expectedStatusCode, connect.CodeOf(err))
+			} else {
+				require.Nil(t, err)
+			}
+		})
+	}
+}
diff --git a/internal/api/promote_stage_v1alpha1_test.go b/internal/api/promote_stage_v1alpha1_test.go
index 71eeaae9..2b4e5739 100644
--- a/internal/api/promote_stage_v1alpha1_test.go
+++ b/internal/api/promote_stage_v1alpha1_test.go
@@ -79,10 +79,7 @@ func TestPromoteStage(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
-				require.Equal(t, "something went wrong", connErr.Message())
+				require.Equal(t, "get stage: something went wrong", err.Error())
 			},
 		},
 		{
@@ -157,10 +154,7 @@ func TestPromoteStage(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
-				require.Equal(t, "something went wrong", connErr.Message())
+				require.Equal(t, "get freight: something went wrong", err.Error())
 			},
 		},
 		{
@@ -313,10 +307,7 @@ func TestPromoteStage(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
-				require.Equal(t, connErr.Message(), "something went wrong")
+				require.Equal(t, "create promotion: something went wrong", err.Error())
 			},
 		},
 		{
diff --git a/internal/api/promote_subscribers_v1alpha1_test.go b/internal/api/promote_subscribers_v1alpha1_test.go
index 05ddd490..314a13ec 100644
--- a/internal/api/promote_subscribers_v1alpha1_test.go
+++ b/internal/api/promote_subscribers_v1alpha1_test.go
@@ -81,10 +81,7 @@ func TestPromoteSubscribers(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
-				require.Equal(t, "something went wrong", connErr.Message())
+				require.Equal(t, "get stage: something went wrong", err.Error())
 			},
 		},
 		{
@@ -159,10 +156,7 @@ func TestPromoteSubscribers(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
-				require.Equal(t, "something went wrong", connErr.Message())
+				require.Equal(t, "get freight: something went wrong", err.Error())
 			},
 		},
 		{
@@ -314,10 +308,7 @@ func TestPromoteSubscribers(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
-				require.Equal(t, "something went wrong", connErr.Message())
+				require.Equal(t, "find stage subscribers: something went wrong", err.Error())
 			},
 		},
 		{
@@ -373,7 +364,7 @@ func TestPromoteSubscribers(t *testing.T) {
 				connErr, ok := err.(*connect.Error)
 				require.True(t, ok)
 				require.Equal(t, connect.CodeNotFound, connErr.Code())
-				require.Contains(t, connErr.Message(), "Stage")
+				require.Contains(t, connErr.Message(), "stage")
 				require.Contains(t, connErr.Message(), "has no subscribers")
 			},
 		},
diff --git a/internal/api/query_freights_v1alpha1_test.go b/internal/api/query_freights_v1alpha1_test.go
index 903bad1e..b3350eb6 100644
--- a/internal/api/query_freights_v1alpha1_test.go
+++ b/internal/api/query_freights_v1alpha1_test.go
@@ -108,9 +108,6 @@ func TestQueryFreight(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
 			},
 		},
 
@@ -182,9 +179,6 @@ func TestQueryFreight(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
 			},
 		},
 
@@ -211,10 +205,7 @@ func TestQueryFreight(t *testing.T) {
 				err error,
 			) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
-				require.Contains(t, connErr.Message(), "something went wrong")
+				require.Contains(t, err.Error(), "something went wrong")
 			},
 		},
 
diff --git a/internal/api/validators_test.go b/internal/api/validators_test.go
index 74c184ec..6f145531 100644
--- a/internal/api/validators_test.go
+++ b/internal/api/validators_test.go
@@ -68,9 +68,6 @@ func TestValidateProject(t *testing.T) {
 			},
 			assertions: func(err error) {
 				require.Error(t, err)
-				connErr, ok := err.(*connect.Error)
-				require.True(t, ok)
-				require.Equal(t, connect.CodeInternal, connErr.Code())
 			},
 		},
 		{
diff --git a/internal/controller/promotion/root_test.go b/internal/controller/promotion/root_test.go
index f037498a..c0dfc384 100644
--- a/internal/controller/promotion/root_test.go
+++ b/internal/controller/promotion/root_test.go
@@ -14,7 +14,10 @@ import (
 func TestNewMechanisms(t *testing.T) {
 	promoMechs := NewMechanisms(
 		fake.NewClientBuilder().Build(),
-		credentials.NewKubernetesDatabase(nil),
+		credentials.NewKubernetesDatabase(
+			nil,
+			nil, credentials.KubernetesDatabaseConfig{},
+		),
 	)
 	require.IsType(t, &compositeMechanism{}, promoMechs)
 }
diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go
index b86d80eb..59386f5f 100644
--- a/internal/credentials/credentials_test.go
+++ b/internal/credentials/credentials_test.go
@@ -12,19 +12,18 @@ import (
 )
 
 func TestNewKubernetesDatabase(t *testing.T) {
-	const testArgoCDNameSpace = "argocd"
 	testClient := fake.NewClientBuilder().Build()
-	d := NewKubernetesDatabase(
-		testClient,
-		WithArgoCDNamespace(testArgoCDNameSpace),
-		WithArgoClient(testClient),
-	)
+	testCfg := KubernetesDatabaseConfig{
+		ArgoCDNamespace:             "fake-namespace",
+		GlobalCredentialsNamespaces: []string{"another-fake-namespace"},
+	}
+	d := NewKubernetesDatabase(testClient, testClient, testCfg)
 	require.NotNil(t, d)
 	k, ok := d.(*kubernetesDatabase)
 	require.True(t, ok)
-	require.Equal(t, testArgoCDNameSpace, k.ArgoCDNamespace)
 	require.Same(t, testClient, k.kargoClient)
 	require.Same(t, testClient, k.argoClient)
+	require.Equal(t, testCfg, k.cfg)
 }
 
 func TestGetCredentialsSecret(t *testing.T) {
diff --git a/internal/image/repository_client_test.go b/internal/image/repository_client_test.go
index 3cf11dd7..5a2111ff 100644
--- a/internal/image/repository_client_test.go
+++ b/internal/image/repository_client_test.go
@@ -603,6 +603,18 @@ func TestExtractTagFromOCIManifest(t *testing.T) {
 				require.Contains(t, err.Error(), "error unmarshaling blob")
 			},
 		},
+		{
+			name: "doesn't look like an image",
+			client: &repositoryClient{
+				getBlobFn: func(context.Context, digest.Digest) ([]byte, error) {
+					return []byte(`{"os": "", "architecture": ""}`), nil
+				},
+			},
+			assertions: func(tag *Tag, err error) {
+				require.NoError(t, err)
+				require.Nil(t, tag)
+			},
+		},
 		{
 			name: "platform does not match",
 			platform: &platformConstraint{
@@ -623,7 +635,7 @@ func TestExtractTagFromOCIManifest(t *testing.T) {
 			name: "error parsing timestamp",
 			client: &repositoryClient{
 				getBlobFn: func(context.Context, digest.Digest) ([]byte, error) {
-					return []byte(`{"created": "junk"}`), nil
+					return []byte(`{"os": "linux", "architecture": "arm64", "created": "junk"}`), nil
 				},
 			},
 			assertions: func(_ *Tag, err error) {