|
| 1 | +# OpenStackClusterIdentity for Centralized Credential Management |
| 2 | + |
| 3 | +## Metadata |
| 4 | + |
| 5 | +- **Authors**: @bnallapeta |
| 6 | +- **Reviewers**: CAPO maintainers (@mdbooth) |
| 7 | +- **Status**: Proposed |
| 8 | +- **Creation Date**: 2025-07-22 |
| 9 | +- **Last Updated**: 2025-07-29 |
| 10 | + |
| 11 | +## Summary |
| 12 | + |
| 13 | +This proposal introduces `OpenStackClusterIdentity`, a cluster-scoped resource for centralized OpenStack credential management in CAPO. This enables multi-tenant environments to share credentials across namespaces while maintaining proper access controls, following patterns from AWS and Azure Cluster API providers. |
| 14 | + |
| 15 | +## Motivation |
| 16 | + |
| 17 | +### Goals |
| 18 | +- Enable centralized storage of OpenStack credentials in cluster-scoped resources |
| 19 | +- Provide fine-grained namespace access controls for credential usage |
| 20 | +- Maintain 100% backward compatibility with existing OpenStackCluster resources |
| 21 | +- Support manual, gradual migration without breaking existing deployments |
| 22 | +- Follow established patterns from other Cluster API providers (AWS, Azure) |
| 23 | + |
| 24 | +### Non-Goals |
| 25 | +- Automatic migration of existing deployments |
| 26 | +- Integration with external secret management systems |
| 27 | +- Breaking changes to existing API or functionality |
| 28 | + |
| 29 | +### User Stories |
| 30 | + |
| 31 | +#### Story 1: Platform Administrator |
| 32 | +As a platform administrator managing multiple tenant namespaces, I want to store OpenStack credentials centrally in a secure namespace (e.g., `capo-system`), control which tenant namespaces can use specific credentials, and rotate credentials in one place without updating every namespace. |
| 33 | + |
| 34 | +#### Story 2: Tenant User |
| 35 | +As a tenant user in namespace `team-a`, I want to create OpenStack clusters using centrally managed credentials without managing OpenStack secrets in my namespace, with clear error messages if I don't have permission to use specific credentials. |
| 36 | + |
| 37 | +#### Story 3: Multi-Region Setup |
| 38 | +As an administrator managing clusters across multiple OpenStack regions, I want to create region-specific cluster identities with appropriate credentials and allow tenants to use different regional credentials based on their needs. |
| 39 | + |
| 40 | +### API Design |
| 41 | + |
| 42 | +#### New OpenStackClusterIdentity Resource (Cluster-scoped) |
| 43 | + |
| 44 | +```go |
| 45 | +type OpenStackClusterIdentity struct { |
| 46 | + metav1.TypeMeta `json:",inline"` |
| 47 | + metav1.ObjectMeta `json:"metadata,omitempty"` |
| 48 | + Spec OpenStackClusterIdentitySpec `json:"spec,omitempty"` |
| 49 | +} |
| 50 | + |
| 51 | +type OpenStackClusterIdentitySpec struct { |
| 52 | + // SecretRef references the secret containing OpenStack credentials |
| 53 | + SecretRef OpenStackCredentialSecretReference `json:"secretRef"` |
| 54 | + |
| 55 | + // NamespaceSelector selects allowed namespaces via labels |
| 56 | + // All namespaces have a kubernetes.io/metadata.name label containing their name |
| 57 | + // +optional |
| 58 | + NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"` |
| 59 | +} |
| 60 | + |
| 61 | +type OpenStackCredentialSecretReference struct { |
| 62 | + Name string `json:"name"` |
| 63 | + Namespace string `json:"namespace"` |
| 64 | +} |
| 65 | +``` |
| 66 | + |
| 67 | +#### Enhanced OpenStackIdentityReference (Discriminated Union) |
| 68 | + |
| 69 | +We extend the existing `identityRef` to support multiple types using a discriminated union pattern: |
| 70 | + |
| 71 | +```go |
| 72 | +type OpenStackIdentityReference struct { |
| 73 | + // Type specifies the identity reference type |
| 74 | + // +kubebuilder:validation:Enum=Secret;ClusterIdentity |
| 75 | + // +kubebuilder:default=Secret |
| 76 | + // +kubebuilder:validation:XValidation:rule="self == 'Secret' ? has(self.cloudName) : !has(self.cloudName)",message="cloudName required for Secret type, forbidden for ClusterIdentity type" |
| 77 | + // +kubebuilder:validation:XValidation:rule="has(self.name)",message="name is required" |
| 78 | + // +optional |
| 79 | + Type string `json:"type,omitempty"` |
| 80 | + |
| 81 | + // Name of the secret (type=Secret) or cluster identity (type=ClusterIdentity) |
| 82 | + // +optional |
| 83 | + Name string `json:"name,omitempty"` |
| 84 | + |
| 85 | + // CloudName required for Secret type, forbidden for ClusterIdentity type |
| 86 | + // +optional |
| 87 | + CloudName string `json:"cloudName,omitempty"` |
| 88 | + |
| 89 | + // Region applies to both types |
| 90 | + // +optional |
| 91 | + Region string `json:"region,omitempty"` |
| 92 | +} |
| 93 | +``` |
| 94 | + |
| 95 | +### Implementation Details |
| 96 | + |
| 97 | +#### Credential Resolution Logic |
| 98 | +The scope factory will implement type-based credential resolution: |
| 99 | + |
| 100 | +```go |
| 101 | +func (f *providerScopeFactory) resolveCredentials(identityRef *infrav1.OpenStackIdentityReference) { |
| 102 | + switch identityRef.Type { |
| 103 | + case "ClusterIdentity": |
| 104 | + return f.newScopeFromClusterIdentity(identityRef.Name) |
| 105 | + case "Secret", "": // Default to Secret for backward compatibility |
| 106 | + return f.newScopeFromSecretIdentity(identityRef) |
| 107 | + default: |
| 108 | + return fmt.Errorf("unsupported identity type: %s", identityRef.Type) |
| 109 | + } |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +#### Permission and Access Control |
| 114 | + |
| 115 | +This feature involves two distinct types of permissions: |
| 116 | + |
| 117 | +**1. Controller RBAC (Kubernetes-level permissions)** |
| 118 | +The CAPO controller already has cluster-wide secret access. We only need to add: |
| 119 | + |
| 120 | +```yaml |
| 121 | +# ADD to existing config/rbac/role.yaml |
| 122 | +- apiGroups: [""] |
| 123 | + resources: ["namespaces"] |
| 124 | + verbs: ["get"] # Read namespace metadata for validation |
| 125 | +- apiGroups: ["infrastructure.cluster.x-k8s.io"] |
| 126 | + resources: ["openstackclusteridentities"] |
| 127 | + verbs: ["get", "list", "watch"] # Manage cluster identities |
| 128 | +``` |
| 129 | +
|
| 130 | +**2. Namespace Access Control (Application-level permissions)** |
| 131 | +The cluster identity defines which namespaces can use it via namespace selectors: |
| 132 | +
|
| 133 | +```go |
| 134 | +func (r *OpenStackClusterReconciler) validateNamespaceAccess(identity *OpenStackClusterIdentity, namespace string) error { |
| 135 | + // If no selector specified, allow all namespaces |
| 136 | + if identity.Spec.NamespaceSelector == nil { |
| 137 | + return nil |
| 138 | + } |
| 139 | + |
| 140 | + // Get the namespace object |
| 141 | + ns := &corev1.Namespace{} |
| 142 | + err := r.Client.Get(ctx, types.NamespacedName{Name: namespace}, ns) |
| 143 | + if err != nil { |
| 144 | + return fmt.Errorf("failed to get namespace %s: %w", namespace, err) |
| 145 | + } |
| 146 | + |
| 147 | + // Check if namespace matches the selector |
| 148 | + selector, err := metav1.LabelSelectorAsSelector(identity.Spec.NamespaceSelector) |
| 149 | + if err != nil { |
| 150 | + return fmt.Errorf("invalid namespace selector: %w", err) |
| 151 | + } |
| 152 | + |
| 153 | + if !selector.Matches(labels.Set(ns.Labels)) { |
| 154 | + return fmt.Errorf("namespace %s not allowed to use cluster identity %s", |
| 155 | + namespace, identity.Name) |
| 156 | + } |
| 157 | + |
| 158 | + return nil |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +**Key Edge Cases:** |
| 163 | +- Missing type field: Defaults to `Secret` behavior (100% backward compatible) |
| 164 | +- Invalid type: Clear error message |
| 165 | +- Invalid field combinations: CEL validation prevents misconfigurations |
| 166 | +- Namespace access denied: Clear error message with identity name and namespace |
| 167 | + |
| 168 | +#### CEL Validation |
| 169 | + |
| 170 | +We use CEL (Common Expression Language) for API validation, following existing CAPO patterns: |
| 171 | + |
| 172 | +```go |
| 173 | +type OpenStackIdentityReference struct { |
| 174 | + // Type specifies the identity reference type |
| 175 | + // +kubebuilder:validation:Enum=Secret;ClusterIdentity |
| 176 | + // +kubebuilder:default=Secret |
| 177 | + // +kubebuilder:validation:XValidation:rule="self == 'Secret' ? has(self.cloudName) : !has(self.cloudName)",message="cloudName required for Secret type, forbidden for ClusterIdentity type" |
| 178 | + // +kubebuilder:validation:XValidation:rule="has(self.name)",message="name is required" |
| 179 | + // +optional |
| 180 | + Type string `json:"type,omitempty"` |
| 181 | + |
| 182 | + // Name of the secret (type=Secret) or cluster identity (type=ClusterIdentity) |
| 183 | + // +optional |
| 184 | + Name string `json:"name,omitempty"` |
| 185 | + |
| 186 | + // CloudName required for Secret type, forbidden for ClusterIdentity type |
| 187 | + // +optional |
| 188 | + CloudName string `json:"cloudName,omitempty"` |
| 189 | + |
| 190 | + // Region applies to both types |
| 191 | + // +optional |
| 192 | + Region string `json:"region,omitempty"` |
| 193 | +} |
| 194 | +``` |
| 195 | + |
| 196 | +**CEL Validation Rules:** |
| 197 | +1. **Name Required**: `name` field is always required for both types |
| 198 | +2. **CloudName Logic**: Required for Secret type, forbidden for ClusterIdentity type |
| 199 | +3. **Type Safety**: Enum validation ensures only valid types are accepted |
| 200 | + |
| 201 | +### Backward Compatibility |
| 202 | + |
| 203 | +- **Perfect backward compatibility**: Existing `identityRef` configurations work unchanged |
| 204 | +- **Default behavior**: Missing `type` field defaults to `Secret` behavior |
| 205 | +- **No migration required**: Existing clusters continue working |
| 206 | +- **Gradual adoption**: Users can adopt `type: ClusterIdentity` when required |
| 207 | + |
| 208 | +#### Migration Strategy |
| 209 | +- **No forced migration**: Existing deployments continue working indefinitely |
| 210 | +- **Manual opt-in**: Users add `type: ClusterIdentity` when ready |
| 211 | +- **Clear validation**: Type-specific field validation prevents misconfigurations |
| 212 | + |
| 213 | +### Testing Strategy |
| 214 | + |
| 215 | +**Unit Tests**: API validation for both identity types, credential resolution logic, namespace access validation |
| 216 | +**Integration Tests**: End-to-end credential resolution for both types, cross-namespace access validation |
| 217 | +**E2E Tests**: Full cluster lifecycle with both identity types, mixed deployments |
| 218 | +**Security Tests**: Unauthorized access attempts, namespace boundary enforcement |
| 219 | + |
| 220 | +## Risks and Mitigations |
| 221 | + |
| 222 | +| Risk | Mitigation | |
| 223 | +|------|------------| |
| 224 | +| Complex validation logic | Type-specific validation, comprehensive testing | |
| 225 | +| Field confusion | Clear documentation, validation webhooks | |
| 226 | +| Migration issues | Extensive backward compatibility testing | |
| 227 | +| Cross-namespace security | Strict namespace selector validation, audit logging | |
| 228 | + |
| 229 | +## Alternatives |
| 230 | + |
| 231 | +**Separate clusterIdentityRef field**: Requires dual fields, harder to maintain long-term |
| 232 | +**External Secret Operator**: More complex, adds external dependency |
| 233 | +**ConfigMap-based References**: No validation, security concerns |
| 234 | + |
| 235 | +## Example Usage |
| 236 | + |
| 237 | +### Create Cluster Identity |
| 238 | +```yaml |
| 239 | +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 |
| 240 | +kind: OpenStackClusterIdentity |
| 241 | +metadata: |
| 242 | + name: production-openstack |
| 243 | +spec: |
| 244 | + secretRef: |
| 245 | + name: openstack-credentials |
| 246 | + namespace: capo-system |
| 247 | + namespaceSelector: |
| 248 | + matchExpressions: |
| 249 | + - key: kubernetes.io/metadata.name |
| 250 | + operator: In |
| 251 | + values: [team-a, team-b] |
| 252 | +--- |
| 253 | +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 |
| 254 | +kind: OpenStackClusterIdentity |
| 255 | +metadata: |
| 256 | + name: development-openstack |
| 257 | +spec: |
| 258 | + secretRef: |
| 259 | + name: dev-openstack-credentials |
| 260 | + namespace: capo-system |
| 261 | + namespaceSelector: |
| 262 | + matchLabels: |
| 263 | + environment: development |
| 264 | +``` |
| 265 | +
|
| 266 | +### Current Secret-based Identity (unchanged) |
| 267 | +```yaml |
| 268 | +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 |
| 269 | +kind: OpenStackCluster |
| 270 | +metadata: |
| 271 | + name: cluster-a |
| 272 | + namespace: team-a |
| 273 | +spec: |
| 274 | + identityRef: |
| 275 | + # No type field = defaults to Secret behavior |
| 276 | + name: my-secret |
| 277 | + cloudName: openstack |
| 278 | +``` |
| 279 | +
|
| 280 | +### New ClusterIdentity-based (new usage) |
| 281 | +```yaml |
| 282 | +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 |
| 283 | +kind: OpenStackCluster |
| 284 | +metadata: |
| 285 | + name: cluster-a |
| 286 | + namespace: team-a |
| 287 | +spec: |
| 288 | + identityRef: |
| 289 | + type: ClusterIdentity |
| 290 | + name: prod-openstack |
| 291 | +``` |
| 292 | +
|
| 293 | +### Explicit Secret Type (optional) |
| 294 | +```yaml |
| 295 | +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 |
| 296 | +kind: OpenStackCluster |
| 297 | +metadata: |
| 298 | + name: cluster-a |
| 299 | + namespace: team-a |
| 300 | +spec: |
| 301 | + identityRef: |
| 302 | + type: Secret |
| 303 | + name: my-secret |
| 304 | + cloudName: openstack |
| 305 | +``` |
| 306 | +
|
| 307 | +## Implementation Notes |
| 308 | +
|
| 309 | +**API Changes**: |
| 310 | +- Extend `OpenStackIdentityReference` with `type` field with `Secret` and `ClusterIdentity` as the only supported values |
| 311 | +- Add CEL validation rules for type-specific field combinations |
| 312 | +- Update CRD generation for new field |
| 313 | + |
| 314 | +**RBAC Changes**: |
| 315 | +- Add namespace `get` permission to existing `config/rbac/role.yaml` |
| 316 | +- Add `openstackclusteridentities` resource permissions |
| 317 | + |
| 318 | +**Controller Changes**: |
| 319 | +- Modify `pkg/scope/provider.go` for type-based credential resolution |
| 320 | +- Add namespace access validation logic in controllers |
| 321 | +- Add cluster identity lookup and permission checking |
| 322 | +- Implement comprehensive error handling with clear messages |
| 323 | + |
| 324 | +**Backward Compatibility**: |
| 325 | +- Default `type` to `Secret` when not specified |
| 326 | +- CEL validation maintains existing field requirements for secret-based identities |
| 327 | +- Ensure all existing configurations continue working |
| 328 | + |
| 329 | +**Security Considerations**: |
| 330 | +- Validate namespace access on every cluster reconciliation |
| 331 | +- Log access attempts for audit purposes |
| 332 | +- Clear error messages for permission denials |
| 333 | +- Fail securely when cluster identity is not accessible |
| 334 | + |
| 335 | +**Broader Impact**: |
| 336 | +This change automatically enables cluster identity support for: |
| 337 | +- `OpenStackCluster` resources |
| 338 | +- `OpenStackMachine` resources |
| 339 | +- `OpenStackServer` resources |
| 340 | + |
| 341 | +This proposal provides centralized credential management while maintaining full backward compatibility and following established Kubernetes patterns (discriminated union). |
0 commit comments