From 40ad98fb9f23d3611c4dadf2b4b719ba67415cd9 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 20 May 2026 18:16:15 +0100 Subject: [PATCH 1/3] docs: propose quota display names and product grouping Add an enhancement proposal for surfacing human-readable resource labels and product-level grouping on the quota system. Key changes proposed: - New cluster-scoped Product CRD under quota.miloapis.com/v1alpha1 carrying display name and description for a product (e.g. AI Edge) - New optional displayName and productRef fields on ResourceRegistrationSpec so each registered resource can advertise its friendly label and product membership - Propagation of display metadata into AllowanceBucket.status so the cloud portal renders the quota page from a single list call - Staff-portal admin surface for curating Product objects and editing the new display fields, layered on top of the Kubernetes API Tracking issue: datum-cloud/enhancements#735 --- .../quota/display-names-and-products.md | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 docs/enhancements/quota/display-names-and-products.md diff --git a/docs/enhancements/quota/display-names-and-products.md b/docs/enhancements/quota/display-names-and-products.md new file mode 100644 index 00000000..a4ab0723 --- /dev/null +++ b/docs/enhancements/quota/display-names-and-products.md @@ -0,0 +1,394 @@ +# Quota display names and product grouping + +## Overview + +The cloud-portal quota page (Project → Quotas) currently renders one row per +`AllowanceBucket`, with the raw `spec.resourceType` string in the "Resource +Type" column — for example `gateway.networking.k8s.io/httproutes` or +`networking.datumapis.com/trafficprotectionpolicies`. There are two +shortcomings: + +1. **Unfriendly labels.** Customers see API identifiers rather than the + product-level name they recognize (e.g. "HTTP Routes"). Support and + sales repeatedly translate these strings for users. +2. **No grouping.** Resources that together compose a Datum product are + scattered alphabetically. "AI Edge" is composed of HTTP routes, HTTP + proxies, gateways, traffic protection policies, and more, but the UI + gives no signal of that bundle. + +Today there is no Milo API surface for either. `ResourceRegistration` has +only a free-text `description` and unit-display fields (`displayUnit`, +`unitConversionFactor`) — nothing for resource display name or product +membership. The portal renders `spec.resourceType` straight through with +no transformation +(`cloud-portal/app/features/quotas/quotas-table.tsx`). + +This enhancement introduces: + +- A new cluster-scoped `Product` CRD under + `quota.miloapis.com/v1alpha1` that carries product-level display + metadata. +- New fields on `ResourceRegistrationSpec`: `displayName` (per-resource + human label) and `productRef` (reference to a `Product`). +- Propagation of `displayName`, `productRef`, and the product display + name into `AllowanceBucket.status` so the portal renders the quota + page from a single resource list, without an additional join. +- A portal change to group bucket rows by product display name and + render the friendly resource label. +- A staff-portal admin surface for managing `Product` objects and + editing `displayName` / `productRef` on `ResourceRegistration`, so + GTM and platform operators can curate the quota catalog without + hand-editing YAML. + +## Goals + +- Provide a stable API surface for resource display names and product + grouping that survives renames of underlying API identifiers. +- Let the portal render the quota page from `AllowanceBucket` alone, + with no extra fetch of `ResourceRegistration` or `Product` from the + consumer's project control plane. +- Keep changes backward-compatible: rows still render if `displayName` + or `productRef` is absent — falling back to `resourceType` and an + "Other" group respectively. +- Allow a resource to belong to at most one product + (one-product-to-many-resources). Multi-product membership is + out of scope. + +## Non-Goals + +- Internationalization / localization of display names. +- Pricing, SKUs, or billing metadata on `Product` — display only. +- Curated ordering. Portal sorts alphabetically within each group, and + groups alphabetically by product display name. Explicit ordering + hints are deferred. +- Marketing assets such as icons or logos. Can be added later as + optional fields; not part of this proposal. +- Per-tier or per-plan grouping ("Enterprise" vs "Free"). Out of scope. + +## API + +### New CRD: `Product` + +Cluster-scoped, lives in `quota.miloapis.com/v1alpha1` alongside the +existing quota types under +[`pkg/apis/quota/v1alpha1/`](../../../pkg/apis/quota/v1alpha1). + +```go +// quota/v1alpha1/product_types.go + +type ProductSpec struct { + // DisplayName is the human-readable product name shown in UIs. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=100 + DisplayName string `json:"displayName"` + + // Description is short explanatory copy shown alongside the product + // header in the quota UI. Maximum 500 characters. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxLength=500 + Description string `json:"description,omitempty"` +} + +type ProductStatus struct { + // ObservedGeneration tracks the spec generation last reconciled. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions tracks validation and readiness state. + Conditions []metav1.Condition `json:"conditions,omitempty"` +} +``` + +`metadata.name` is the stable identifier referenced from +`ResourceRegistration.spec.productRef.name`. Conventional form is +kebab-case: `ai-edge`, `core-networking`, `compute`. + +Example: + +```yaml +apiVersion: quota.miloapis.com/v1alpha1 +kind: Product +metadata: + name: ai-edge +spec: + displayName: AI Edge + description: HTTP-layer routing and traffic protection for AI workloads. +``` + +### `ResourceRegistration` additions + +Two new optional fields on `ResourceRegistrationSpec` +([`resourceregistration_types.go`](../../../pkg/apis/quota/v1alpha1/resourceregistration_types.go)): + +```go +// DisplayName is the human-readable resource label shown in UIs +// (e.g. "HTTP Routes", "Traffic Protection Policies"). When unset, the +// portal falls back to spec.resourceType. +// +// +kubebuilder:validation:Optional +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=100 +DisplayName string `json:"displayName,omitempty"` + +// ProductRef groups this registration under a Product for display. +// References a Product by name in the same cluster. When unset, the +// resource renders in an "Other" group in the portal. +// +// +kubebuilder:validation:Optional +ProductRef *ProductReference `json:"productRef,omitempty"` +``` + +```go +type ProductReference struct { + // Name of the Product resource. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} +``` + +Both new fields are mutable (unlike `resourceType` and `type`). Updates +propagate to downstream buckets on the next reconciliation. + +Example updated registration: + +```yaml +apiVersion: quota.miloapis.com/v1alpha1 +kind: ResourceRegistration +metadata: + name: httproutes-per-project +spec: + consumerType: + apiGroup: resourcemanager.miloapis.com + kind: Project + type: Entity + resourceType: gateway.networking.k8s.io/httproutes + displayName: HTTP Routes + productRef: + name: ai-edge + description: Maximum number of HTTP routes that can be created within a project + baseUnit: route + displayUnit: routes + unitConversionFactor: 1 + claimingResources: + - apiGroup: gateway.networking.k8s.io + kind: HTTPRoute +``` + +### `AllowanceBucket.status` propagation + +The bucket controller already aggregates state from `ResourceRegistration` +(see [`allowancebucket_types.go`](../../../pkg/apis/quota/v1alpha1/allowancebucket_types.go)). +Extend it to copy display metadata into status so the portal can render +the quota page from a single list call: + +```go +type AllowanceBucketStatus struct { + // ... existing fields ... + + // DisplayName mirrors the matching ResourceRegistration.spec.displayName + // at last reconciliation. Empty if the registration has no display name. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxLength=100 + DisplayName string `json:"displayName,omitempty"` + + // Product surfaces product grouping metadata for UI rendering. + // Nil if the matching ResourceRegistration has no productRef, or if + // the referenced Product does not exist (a status condition records + // the dangling reference). + // + // +kubebuilder:validation:Optional + Product *BucketProductInfo `json:"product,omitempty"` +} + +type BucketProductInfo struct { + // Name is the Product.metadata.name reference. + // + // +kubebuilder:validation:Required + Name string `json:"name"` + + // DisplayName mirrors Product.spec.displayName at last reconciliation. + // + // +kubebuilder:validation:Required + DisplayName string `json:"displayName"` +} +``` + +The bucket controller becomes the single denormalization point: when a +`ResourceRegistration` or `Product` changes, dependent buckets are +requeued and re-reconciled. The portal never needs to GET +`ResourceRegistration` or `Product` directly. + +## Controller Behavior + +### Reconciliation triggers + +The existing bucket reconciler already watches `ResourceRegistration` and +the consumer-side `ResourceGrant` / `ResourceClaim` populations. Two new +watches: + +- `Product` change → enqueue all `ResourceRegistration` objects with a + matching `spec.productRef.name`, which in turn enqueues their + dependent buckets. +- `ResourceRegistration.spec.displayName` or `spec.productRef` change → + enqueue dependent buckets (this is already implicit if the reconciler + reacts to any registration change; otherwise add a predicate). + +### Resolution + +For each bucket reconciliation: + +1. Resolve the `ResourceRegistration` matching `spec.resourceType` and + `spec.consumerRef.kind` (as today). +2. Copy `registration.spec.displayName` into `status.displayName`. +3. If `registration.spec.productRef` is set, GET the referenced + `Product`: + - On success, populate `status.product` with `{name, displayName}`. + - On NotFound, clear `status.product` and set condition + `ProductRefResolved=False` with reason `ProductNotFound`. Do not + fail reconciliation — the bucket should still serve quota. +4. If `registration.spec.productRef` is unset, clear `status.product` and + set `ProductRefResolved=True` with reason `NoProductRef`. + +### Validation + +- The new fields on `ResourceRegistration` and `Product` are validated + via kubebuilder length / required markers — no admission webhook + changes needed. +- `productRef.name` is **not** validated against existing `Product` + objects at admission time. Dangling references are tolerated and + surfaced through bucket status (GTM may create registrations before + products land). + +## Portal Changes + +Two files in cloud-portal: + +- [`app/resources/allowance-buckets/allowance-bucket.adapter.ts`](https://github.com/datum-cloud/cloud-portal/blob/main/app/resources/allowance-buckets/allowance-bucket.adapter.ts): + pass through `status.displayName` and `status.product` onto the + view-model. +- [`app/features/quotas/quotas-table.tsx`](https://github.com/datum-cloud/cloud-portal/blob/main/app/features/quotas/quotas-table.tsx): + group rows by `status.product.displayName` (rendering a section + header per group), and render `status.displayName` in the Resource + Type column, falling back to `spec.resourceType` when empty. Buckets + with no `status.product` group under "Other", sorted last. + +No new API surface in the portal — the existing AllowanceBucket list +endpoint carries everything needed. + +## Staff Portal Changes + +The catalog of products and per-resource display names is curated by +the GTM and platform teams, so the staff portal gains a small admin +surface that writes directly to the Milo CRDs introduced above. No new +HTTP API is added — the staff portal already speaks to Milo via the +Kubernetes API (see [`feedback_portal_milo_via_k8s_api`] pattern), so +this is a UI on top of existing `quota.miloapis.com/v1alpha1` types. + +New pages under the staff portal's existing admin section: + +- **Products list.** Lists all `Product` objects with display name, + description, and count of `ResourceRegistration` objects pointing at + them. Actions: create, edit, delete. +- **Product detail / edit.** Edits `Product.spec.displayName` and + `Product.spec.description`. Lists the registrations that reference + it (read-only summary). +- **Resource registrations list.** Lists all `ResourceRegistration` + objects with `resourceType`, `displayName`, `productRef`, and the + bucket count derived from `AllowanceBucket` field selectors. +- **Resource registration edit.** Edits the two mutable display fields: + `spec.displayName` and `spec.productRef.name` (with a dropdown + populated from the `Product` list, plus a "None" choice that clears + the ref). Immutable fields (`resourceType`, `type`, `consumerType`, + unit fields, `claimingResources`) are shown read-only. + +Authorization: gated by the existing staff-portal admin role binding — +the same role that already manages quotas elsewhere in the staff +portal. New `ProtectedResource` / `Role` definitions are not required +because `Product` and `ResourceRegistration` are cluster-scoped quota +types that the platform admin role already grants edit access to. + +Validation is server-side via the kubebuilder markers on the CRDs; +the staff portal mirrors the same constraints client-side (length +limits, required fields) for fast feedback but does not become the +source of truth. + +[`feedback_portal_milo_via_k8s_api`]: + +## Verification + +End-to-end on staging: + +1. Apply a `Product` manifest and one updated `ResourceRegistration` + referencing it. +2. `kubectl get resourceregistration -o yaml` — confirm + `displayName` and `productRef` are accepted. +3. Wait for bucket reconciliation. `kubectl get allowancebucket -o yaml` + for a consumer with that resource type — confirm + `status.displayName` and `status.product.displayName` are populated. +4. Delete the `Product`; confirm bucket + `status.conditions[type=ProductRefResolved]=False`, and that the + `status.product` field is cleared. Bucket continues to serve quota. +5. Load the portal quota page for a project that has the affected + buckets. Confirm: + - Rows are grouped by product display name with a header per group. + - Each row shows the friendly display name instead of the API + string. + - Buckets with no `productRef` appear in an "Other" group sorted + last. + - Buckets with no `displayName` fall back to `resourceType`. +6. Chainsaw test in milo covering: + - Registration → product → bucket propagation. + - Product deletion clears bucket status and sets the condition. + - Absent `displayName` / `productRef` reconciles to empty fields. +7. From the staff portal admin section: create a `Product`, attach an + existing `ResourceRegistration` via the edit form, refresh the + consumer-side quota page in cloud-portal, and confirm the new + group + display name appear within one reconciliation cycle. + +## Rollout + +- Land milo CRD changes + bucket controller update in one PR; ship as a + patch release of the quota CRDs. +- Update `network-services-operator` registrations to add + `displayName` and `productRef`, and add initial `Product` manifests + (AI Edge, Core Networking) in a follow-up PR. +- Wire `Product` manifests into the infra deployment via the existing + quota kustomization. Image-updater handles the cascade. +- Cloud-portal change ships independently and is forward-compatible + with buckets that have empty `status.displayName` / + `status.product`. +- Staff-portal admin surface ships once the CRD changes are in + staging — GTM can curate the catalog before the consumer-facing + cloud-portal grouping lands, since updates flow through the bucket + controller regardless of which UI rendered them. + +## Open Questions + +- Should `productRef` be allowed to point at a `Product` that does not + yet exist? (Lean: yes, with the `ProductRefResolved=False` condition, + so GTM can stage registrations before products land.) +- Do we need a `quota.miloapis.com/product` label on + `ResourceRegistration` for selector queries (e.g. "all registrations + belonging to AI Edge")? (Lean: spec field only for now; add a label + later if a real query path appears.) +- Should `Product` carry a `Tier` or similar gating field so that + free-tier consumers see only free-tier products? (Lean: out of scope + — this is a display concern; gating lives at the grant level.) + +## References + +- Existing types: + [`pkg/apis/quota/v1alpha1/resourceregistration_types.go`](../../../pkg/apis/quota/v1alpha1/resourceregistration_types.go), + [`pkg/apis/quota/v1alpha1/allowancebucket_types.go`](../../../pkg/apis/quota/v1alpha1/allowancebucket_types.go). +- Example registrations: + `network-services-operator/config/quota/registrations/*.yaml`. +- Portal table: + `cloud-portal/app/features/quotas/quotas-table.tsx`. +- Staff portal repo: `datum-cloud/staff-portal` — admin UIs follow the + same Milo-via-Kubernetes-API pattern used for existing quota admin + pages. From 483a307dcf9a74e3c10edcd54850a120496b9c40 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 20 May 2026 18:23:35 +0100 Subject: [PATCH 2/3] docs: revise quota enhancement per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of feedback on datum-cloud/enhancements#735: 1. Drop the separate Product CRD. Service-layer modelling belongs in the upcoming Milo service catalog, not in quota.miloapis.com. Add taxonomy fields (product, productDisplayName, category) directly to ResourceRegistrationSpec instead. The service catalog will populate spec.taxonomy once it lands; for now operators author it directly. 2. Drop controller-driven propagation into AllowanceBucket.status. Join registration metadata to buckets at the GraphQL layer via a field resolver with a per-request DataLoader and short TTL cache. No AllowanceBucket CRD changes; ResourceRegistration stays the single source of truth. Cloud-portal switches the quota page to a GraphQL query (already the pattern used for organizations/users). Staff-portal edit surface narrows to ResourceRegistration display-field editing — no Products admin since there is no Product resource. --- .../quota/display-names-and-products.md | 483 +++++++++--------- 1 file changed, 238 insertions(+), 245 deletions(-) diff --git a/docs/enhancements/quota/display-names-and-products.md b/docs/enhancements/quota/display-names-and-products.md index a4ab0723..1ba006be 100644 --- a/docs/enhancements/quota/display-names-and-products.md +++ b/docs/enhancements/quota/display-names-and-products.md @@ -25,39 +25,55 @@ no transformation This enhancement introduces: -- A new cluster-scoped `Product` CRD under - `quota.miloapis.com/v1alpha1` that carries product-level display - metadata. -- New fields on `ResourceRegistrationSpec`: `displayName` (per-resource - human label) and `productRef` (reference to a `Product`). -- Propagation of `displayName`, `productRef`, and the product display - name into `AllowanceBucket.status` so the portal renders the quota - page from a single resource list, without an additional join. -- A portal change to group bucket rows by product display name and - render the friendly resource label. -- A staff-portal admin surface for managing `Product` objects and - editing `displayName` / `productRef` on `ResourceRegistration`, so - GTM and platform operators can curate the quota catalog without - hand-editing YAML. +- A new optional `displayName` field on `ResourceRegistrationSpec` for + the friendly resource label. +- A new optional `taxonomy` block on `ResourceRegistrationSpec` for + product-grouping metadata. Quota does not introduce its own + catalog/product resource — the higher-level Milo **service catalog** + (in flight separately) is the long-term system of record and will + populate these taxonomy fields. For now they're hand-authored on + each registration. +- **GraphQL-layer enrichment** of `AllowanceBucket` in the + graphql-gateway service. A field resolver looks the registration up + by `spec.resourceType` (batched per request) and exposes the + registration's `displayName` and `taxonomy` as additional GraphQL + fields on the `AllowanceBucket` type. No controller writes to + `AllowanceBucket.status`; no CRD change to the bucket. +- A cloud-portal change to query AllowanceBuckets through the GraphQL + gateway (cloud-portal already uses GraphQL for other Milo resources), + group rows by product display name, and render the friendly resource + label. +- A staff-portal edit surface for the two new fields on + `ResourceRegistration`, so platform operators can curate display + metadata without hand-editing YAML before the service catalog lands. ## Goals -- Provide a stable API surface for resource display names and product - grouping that survives renames of underlying API identifiers. -- Let the portal render the quota page from `AllowanceBucket` alone, - with no extra fetch of `ResourceRegistration` or `Product` from the - consumer's project control plane. +- Provide a stable API surface for resource display names and + product-grouping taxonomy that survives renames of underlying API + identifiers. +- Keep `ResourceRegistration` the single source of truth; do not + denormalize display metadata into `AllowanceBucket.status` via + controllers. +- Let UIs render the quota page from a single GraphQL query that joins + buckets to their registration. - Keep changes backward-compatible: rows still render if `displayName` - or `productRef` is absent — falling back to `resourceType` and an + or `taxonomy` is absent — falling back to `resourceType` and an "Other" group respectively. -- Allow a resource to belong to at most one product - (one-product-to-many-resources). Multi-product membership is - out of scope. +- Stay forward-compatible with the upcoming Milo service catalog — + the taxonomy block is intentionally simple so the catalog can + populate it or replace authoring without a CRD-version cut. ## Non-Goals +- A separate product/service catalog CRD inside the quota API group. + Service-layer modelling belongs in the Milo service catalog, not in + `quota.miloapis.com`. +- Controller-driven propagation of display metadata into + `AllowanceBucket.status`. The join happens at read time at the + GraphQL layer. - Internationalization / localization of display names. -- Pricing, SKUs, or billing metadata on `Product` — display only. +- Pricing, SKUs, or billing metadata — display only. - Curated ordering. Portal sorts alphabetically within each group, and groups alphabetically by product display name. Explicit ordering hints are deferred. @@ -67,56 +83,6 @@ This enhancement introduces: ## API -### New CRD: `Product` - -Cluster-scoped, lives in `quota.miloapis.com/v1alpha1` alongside the -existing quota types under -[`pkg/apis/quota/v1alpha1/`](../../../pkg/apis/quota/v1alpha1). - -```go -// quota/v1alpha1/product_types.go - -type ProductSpec struct { - // DisplayName is the human-readable product name shown in UIs. - // - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=100 - DisplayName string `json:"displayName"` - - // Description is short explanatory copy shown alongside the product - // header in the quota UI. Maximum 500 characters. - // - // +kubebuilder:validation:Optional - // +kubebuilder:validation:MaxLength=500 - Description string `json:"description,omitempty"` -} - -type ProductStatus struct { - // ObservedGeneration tracks the spec generation last reconciled. - ObservedGeneration int64 `json:"observedGeneration,omitempty"` - - // Conditions tracks validation and readiness state. - Conditions []metav1.Condition `json:"conditions,omitempty"` -} -``` - -`metadata.name` is the stable identifier referenced from -`ResourceRegistration.spec.productRef.name`. Conventional form is -kebab-case: `ai-edge`, `core-networking`, `compute`. - -Example: - -```yaml -apiVersion: quota.miloapis.com/v1alpha1 -kind: Product -metadata: - name: ai-edge -spec: - displayName: AI Edge - description: HTTP-layer routing and traffic protection for AI workloads. -``` - ### `ResourceRegistration` additions Two new optional fields on `ResourceRegistrationSpec` @@ -124,34 +90,59 @@ Two new optional fields on `ResourceRegistrationSpec` ```go // DisplayName is the human-readable resource label shown in UIs -// (e.g. "HTTP Routes", "Traffic Protection Policies"). When unset, the -// portal falls back to spec.resourceType. +// (e.g. "HTTP Routes", "Traffic Protection Policies"). When unset, UIs +// fall back to spec.resourceType. // // +kubebuilder:validation:Optional // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=100 DisplayName string `json:"displayName,omitempty"` -// ProductRef groups this registration under a Product for display. -// References a Product by name in the same cluster. When unset, the -// resource renders in an "Other" group in the portal. +// Taxonomy carries display-only categorization metadata used by UIs to +// group resources (e.g. by product). The fields are populated either +// manually on each registration today, or by the Milo service catalog +// once that lands. Quota does not interpret these fields — they are +// pure metadata for catalog-style UIs. // // +kubebuilder:validation:Optional -ProductRef *ProductReference `json:"productRef,omitempty"` +Taxonomy *ResourceTaxonomy `json:"taxonomy,omitempty"` ``` ```go -type ProductReference struct { - // Name of the Product resource. +// ResourceTaxonomy describes how a resource should be grouped in +// catalog-style UIs. All fields are display-only. +type ResourceTaxonomy struct { + // Product is the machine identifier of the product this resource + // belongs to (e.g. "ai-edge"). Stable across renames of the + // human-readable label; safe to use as a grouping key. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + Product string `json:"product,omitempty"` + + // ProductDisplayName is the human-readable product name shown in + // UIs (e.g. "AI Edge"). When unset but Product is set, UIs may + // title-case Product as a fallback. // - // +kubebuilder:validation:Required + // +kubebuilder:validation:Optional // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` + // +kubebuilder:validation:MaxLength=100 + ProductDisplayName string `json:"productDisplayName,omitempty"` + + // Category is an optional finer-grained grouping within a product + // (e.g. "Routing", "Protection"). Reserved for future use; UIs may + // ignore it until a clear use case emerges. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxLength=100 + Category string `json:"category,omitempty"` } ``` -Both new fields are mutable (unlike `resourceType` and `type`). Updates -propagate to downstream buckets on the next reconciliation. +Both fields are mutable (unlike `resourceType` and `type`). Edits take +effect on the next GraphQL query. Example updated registration: @@ -167,8 +158,9 @@ spec: type: Entity resourceType: gateway.networking.k8s.io/httproutes displayName: HTTP Routes - productRef: - name: ai-edge + taxonomy: + product: ai-edge + productDisplayName: AI Edge description: Maximum number of HTTP routes that can be created within a project baseUnit: route displayUnit: routes @@ -178,207 +170,202 @@ spec: kind: HTTPRoute ``` -### `AllowanceBucket.status` propagation +### `AllowanceBucket` CRD changes -The bucket controller already aggregates state from `ResourceRegistration` -(see [`allowancebucket_types.go`](../../../pkg/apis/quota/v1alpha1/allowancebucket_types.go)). -Extend it to copy display metadata into status so the portal can render -the quota page from a single list call: +**None.** No new fields on `AllowanceBucketStatus`; no controller +changes to copy registration metadata into the bucket. The bucket +controller continues to do exactly what it does today. -```go -type AllowanceBucketStatus struct { - // ... existing fields ... +### Relationship to the Milo service catalog - // DisplayName mirrors the matching ResourceRegistration.spec.displayName - // at last reconciliation. Empty if the registration has no display name. - // - // +kubebuilder:validation:Optional - // +kubebuilder:validation:MaxLength=100 - DisplayName string `json:"displayName,omitempty"` +The Milo service catalog (in flight as a separate enhancement) is the +long-term system of record for service- and product-level metadata. +Once it lands, the expected migration path is: - // Product surfaces product grouping metadata for UI rendering. - // Nil if the matching ResourceRegistration has no productRef, or if - // the referenced Product does not exist (a status condition records - // the dangling reference). - // - // +kubebuilder:validation:Optional - Product *BucketProductInfo `json:"product,omitempty"` -} +- Service-catalog entries become the authoring surface for product + identifiers and display names. +- A small reconciler (or generator) populates `spec.taxonomy` on + matching `ResourceRegistration` objects from the catalog. +- The CRD field shape stays the same; what changes is who writes it. -type BucketProductInfo struct { - // Name is the Product.metadata.name reference. - // - // +kubebuilder:validation:Required - Name string `json:"name"` +Encoding the taxonomy directly on `ResourceRegistration` (rather than +introducing a quota-local `Product` resource) avoids a second catalog +inside the quota API group and avoids a forced migration when the +service catalog lands. - // DisplayName mirrors Product.spec.displayName at last reconciliation. - // - // +kubebuilder:validation:Required - DisplayName string `json:"displayName"` -} -``` +## GraphQL Enrichment + +Display metadata is joined to buckets at the GraphQL layer rather than +via controller-driven status propagation. The graphql-gateway already +composes a Milo supergraph dynamically from OpenAPI specs (see +[`graphql-gateway/README.md`](https://github.com/datum-cloud/graphql-gateway)) +and exposes `AllowanceBucket` and `ResourceRegistration` as types in +that supergraph. -The bucket controller becomes the single denormalization point: when a -`ResourceRegistration` or `Product` changes, dependent buckets are -requeued and re-reconciled. The portal never needs to GET -`ResourceRegistration` or `Product` directly. +### Schema extension -## Controller Behavior +Extend the `AllowanceBucket` type with two computed fields that +delegate to the matching `ResourceRegistration`: -### Reconciliation triggers +```graphql +extend type AllowanceBucket { + # Resolved from the matching ResourceRegistration's spec.displayName. + # Falls back to spec.resourceType when unset. + displayName: String -The existing bucket reconciler already watches `ResourceRegistration` and -the consumer-side `ResourceGrant` / `ResourceClaim` populations. Two new -watches: + # Resolved from the matching ResourceRegistration's spec.taxonomy. + # Null when the registration has no taxonomy block. + taxonomy: ResourceTaxonomy +} -- `Product` change → enqueue all `ResourceRegistration` objects with a - matching `spec.productRef.name`, which in turn enqueues their - dependent buckets. -- `ResourceRegistration.spec.displayName` or `spec.productRef` change → - enqueue dependent buckets (this is already implicit if the reconciler - reacts to any registration change; otherwise add a predicate). +type ResourceTaxonomy { + product: String + productDisplayName: String + category: String +} +``` -### Resolution +A custom resolver runs against the existing supergraph: when the +`AllowanceBucket.displayName` or `AllowanceBucket.taxonomy` fields are +selected, it issues a `ResourceRegistration` lookup keyed by +`(spec.consumerRef.kind, spec.resourceType)`. Lookups are batched per +request with a DataLoader so a page that renders dozens of buckets +makes one (cached) registration fetch. -For each bucket reconciliation: +### Caching -1. Resolve the `ResourceRegistration` matching `spec.resourceType` and - `spec.consumerRef.kind` (as today). -2. Copy `registration.spec.displayName` into `status.displayName`. -3. If `registration.spec.productRef` is set, GET the referenced - `Product`: - - On success, populate `status.product` with `{name, displayName}`. - - On NotFound, clear `status.product` and set condition - `ProductRefResolved=False` with reason `ProductNotFound`. Do not - fail reconciliation — the bucket should still serve quota. -4. If `registration.spec.productRef` is unset, clear `status.product` and - set `ProductRefResolved=True` with reason `NoProductRef`. +`ResourceRegistration` objects change rarely. A short in-memory TTL +cache (e.g. 30s) on the resolver is sufficient; consistent across the +duration of a typical portal page render. -### Validation +### No HTTP / REST changes -- The new fields on `ResourceRegistration` and `Product` are validated - via kubebuilder length / required markers — no admission webhook - changes needed. -- `productRef.name` is **not** validated against existing `Product` - objects at admission time. Dangling references are tolerated and - surfaced through bucket status (GTM may create registrations before - products land). +Cloud-portal and staff-portal already consume Milo through the GraphQL +gateway for several resource types +(`cloud-portal/app/resources/organizations/organization.gql-*.ts`, +similar for users). This change uses the same path. Direct +Kubernetes-API readers of `AllowanceBucket` continue to see exactly +the same JSON they see today; they simply don't get the enriched +fields. ## Portal Changes -Two files in cloud-portal: +The cloud-portal quota page switches from a direct AllowanceBucket +fetch to a GraphQL query that selects the enriched fields: -- [`app/resources/allowance-buckets/allowance-bucket.adapter.ts`](https://github.com/datum-cloud/cloud-portal/blob/main/app/resources/allowance-buckets/allowance-bucket.adapter.ts): - pass through `status.displayName` and `status.product` onto the - view-model. +- New GraphQL query under `app/resources/allowance-buckets/` modelled + after the existing `organization.gql-queries.ts` pattern. - [`app/features/quotas/quotas-table.tsx`](https://github.com/datum-cloud/cloud-portal/blob/main/app/features/quotas/quotas-table.tsx): - group rows by `status.product.displayName` (rendering a section - header per group), and render `status.displayName` in the Resource - Type column, falling back to `spec.resourceType` when empty. Buckets - with no `status.product` group under "Other", sorted last. - -No new API surface in the portal — the existing AllowanceBucket list -endpoint carries everything needed. + group rows by `taxonomy.productDisplayName` (rendering a section + header per group), render `displayName` in the Resource Type column, + falling back to `resourceType` when empty. Buckets with no + `taxonomy.product` group under "Other", sorted last. When + `productDisplayName` is empty but `product` is set, title-case + `product` as the header. +- The existing direct-fetch adapter + (`app/resources/allowance-buckets/allowance-bucket.adapter.ts`) is + retired or kept only for paths that don't need the enriched fields. ## Staff Portal Changes -The catalog of products and per-resource display names is curated by -the GTM and platform teams, so the staff portal gains a small admin -surface that writes directly to the Milo CRDs introduced above. No new -HTTP API is added — the staff portal already speaks to Milo via the -Kubernetes API (see [`feedback_portal_milo_via_k8s_api`] pattern), so -this is a UI on top of existing `quota.miloapis.com/v1alpha1` types. +Until the Milo service catalog lands, platform operators need a place +to edit the new display fields without hand-editing YAML. The staff +portal gains a small edit surface on top of `ResourceRegistration`, +using the existing Milo-via-Kubernetes-API pattern (no new HTTP +surface). No "products" admin page is added — there is no `Product` +resource to manage, and the service catalog will eventually own the +authoring experience. New pages under the staff portal's existing admin section: -- **Products list.** Lists all `Product` objects with display name, - description, and count of `ResourceRegistration` objects pointing at - them. Actions: create, edit, delete. -- **Product detail / edit.** Edits `Product.spec.displayName` and - `Product.spec.description`. Lists the registrations that reference - it (read-only summary). - **Resource registrations list.** Lists all `ResourceRegistration` - objects with `resourceType`, `displayName`, `productRef`, and the - bucket count derived from `AllowanceBucket` field selectors. -- **Resource registration edit.** Edits the two mutable display fields: - `spec.displayName` and `spec.productRef.name` (with a dropdown - populated from the `Product` list, plus a "None" choice that clears - the ref). Immutable fields (`resourceType`, `type`, `consumerType`, - unit fields, `claimingResources`) are shown read-only. - -Authorization: gated by the existing staff-portal admin role binding — -the same role that already manages quotas elsewhere in the staff -portal. New `ProtectedResource` / `Role` definitions are not required -because `Product` and `ResourceRegistration` are cluster-scoped quota -types that the platform admin role already grants edit access to. - -Validation is server-side via the kubebuilder markers on the CRDs; -the staff portal mirrors the same constraints client-side (length -limits, required fields) for fast feedback but does not become the -source of truth. - -[`feedback_portal_milo_via_k8s_api`]: + objects with their `resourceType`, `displayName`, + `taxonomy.product`, and `taxonomy.productDisplayName`. +- **Resource registration edit.** Edits only the new mutable display + fields: `spec.displayName`, `spec.taxonomy.product`, + `spec.taxonomy.productDisplayName`, and `spec.taxonomy.category`. + Immutable fields (`resourceType`, `type`, `consumerType`, unit + fields, `claimingResources`) are shown read-only. + +Authorization piggybacks on the existing staff-portal admin role +binding. Server-side validation lives on the CRD; the staff portal +mirrors length / pattern constraints client-side for fast feedback but +does not become the source of truth. + +When the service catalog lands and starts populating `spec.taxonomy` +from a separate authoring surface, this staff-portal page becomes a +read-only viewer for those fields, or is removed entirely. ## Verification End-to-end on staging: -1. Apply a `Product` manifest and one updated `ResourceRegistration` - referencing it. -2. `kubectl get resourceregistration -o yaml` — confirm - `displayName` and `productRef` are accepted. -3. Wait for bucket reconciliation. `kubectl get allowancebucket -o yaml` - for a consumer with that resource type — confirm - `status.displayName` and `status.product.displayName` are populated. -4. Delete the `Product`; confirm bucket - `status.conditions[type=ProductRefResolved]=False`, and that the - `status.product` field is cleared. Bucket continues to serve quota. -5. Load the portal quota page for a project that has the affected +1. Apply an updated `ResourceRegistration` with `displayName` and + `taxonomy` set. `kubectl get resourceregistration -o yaml` + — confirm the new fields are accepted. +2. Run a GraphQL query against the gateway selecting + `AllowanceBucket.displayName` and `AllowanceBucket.taxonomy` for a + consumer that has the affected resource type. Confirm the response + mirrors the registration. +3. Clear `spec.taxonomy` on the registration; rerun the query and + confirm `taxonomy` is `null` (TTL cache aside). +4. Load the portal quota page for a project that has the affected buckets. Confirm: - Rows are grouped by product display name with a header per group. - Each row shows the friendly display name instead of the API string. - - Buckets with no `productRef` appear in an "Other" group sorted - last. + - Buckets with no `taxonomy.product` appear in an "Other" group + sorted last. - Buckets with no `displayName` fall back to `resourceType`. -6. Chainsaw test in milo covering: - - Registration → product → bucket propagation. - - Product deletion clears bucket status and sets the condition. - - Absent `displayName` / `productRef` reconciles to empty fields. -7. From the staff portal admin section: create a `Product`, attach an - existing `ResourceRegistration` via the edit form, refresh the - consumer-side quota page in cloud-portal, and confirm the new - group + display name appear within one reconciliation cycle. +5. Integration test in graphql-gateway covering: + - Registration → bucket enrichment for both `displayName` and + `taxonomy`. + - Missing registration (orphan bucket) yields fallback fields and + null `taxonomy`, no error. + - DataLoader batches multiple buckets into a single registration + lookup per request. +6. From the staff portal admin section: edit a `ResourceRegistration` + to add `displayName` and `taxonomy`, refresh the consumer-side + quota page in cloud-portal, and confirm the new group + display + name appear within one TTL window. ## Rollout -- Land milo CRD changes + bucket controller update in one PR; ship as a - patch release of the quota CRDs. -- Update `network-services-operator` registrations to add - `displayName` and `productRef`, and add initial `Product` manifests - (AI Edge, Core Networking) in a follow-up PR. -- Wire `Product` manifests into the infra deployment via the existing - quota kustomization. Image-updater handles the cascade. -- Cloud-portal change ships independently and is forward-compatible - with buckets that have empty `status.displayName` / - `status.product`. -- Staff-portal admin surface ships once the CRD changes are in - staging — GTM can curate the catalog before the consumer-facing - cloud-portal grouping lands, since updates flow through the bucket - controller regardless of which UI rendered them. +- Land Milo CRD additions (two new optional fields on + `ResourceRegistrationSpec`) in one PR. No bucket controller changes; + no `AllowanceBucket` CRD changes. +- Ship the graphql-gateway resolver extension. Once deployed, + `displayName` and `taxonomy` become queryable on `AllowanceBucket`. +- Update `network-services-operator` registrations to populate + `displayName` and `taxonomy` in a follow-up PR. +- Staff-portal edit surface ships once the CRD changes are in + staging. +- Cloud-portal switches the quota page to the new GraphQL query. + Forward-compatible with registrations that have empty + `displayName` / `taxonomy` (rows still render via the existing + fallbacks). +- When the service catalog lands, the authoring source for + `spec.taxonomy` migrates from hand-edited YAML / staff-portal forms + to catalog-driven reconciliation. No CRD or GraphQL changes + required. ## Open Questions -- Should `productRef` be allowed to point at a `Product` that does not - yet exist? (Lean: yes, with the `ProductRefResolved=False` condition, - so GTM can stage registrations before products land.) -- Do we need a `quota.miloapis.com/product` label on - `ResourceRegistration` for selector queries (e.g. "all registrations - belonging to AI Edge")? (Lean: spec field only for now; add a label - later if a real query path appears.) -- Should `Product` carry a `Tier` or similar gating field so that - free-tier consumers see only free-tier products? (Lean: out of scope - — this is a display concern; gating lives at the grant level.) +- TTL for the registration cache in the resolver: 30s feels right + given how rarely registrations change, but worth confirming once + numbers exist. (Cache miss should be tiny: a single list/get + against Milo.) +- Should `taxonomy.product` carry any uniqueness guarantee across + registrations? (Lean: no — it's a grouping key, not an identifier. + Many registrations sharing the same `product` value is the whole + point.) +- Should the quota CRDs gain a label like + `quota.miloapis.com/product=ai-edge` on `ResourceRegistration` for + selector queries? (Lean: spec field only for now; add a label later + if a real query path appears.) +- Once the service catalog is live, do we deprecate manual editing of + `spec.taxonomy` outright, or leave it as a manual override path? + (Defer to the service catalog enhancement.) ## References @@ -387,8 +374,14 @@ End-to-end on staging: [`pkg/apis/quota/v1alpha1/allowancebucket_types.go`](../../../pkg/apis/quota/v1alpha1/allowancebucket_types.go). - Example registrations: `network-services-operator/config/quota/registrations/*.yaml`. +- GraphQL gateway: `datum-cloud/graphql-gateway` — Hive Gateway with + dynamic supergraph composition from Milo OpenAPI specs. +- Existing portal GraphQL pattern: + `cloud-portal/app/resources/organizations/organization.gql-*.ts`. - Portal table: `cloud-portal/app/features/quotas/quotas-table.tsx`. - Staff portal repo: `datum-cloud/staff-portal` — admin UIs follow the same Milo-via-Kubernetes-API pattern used for existing quota admin pages. +- Milo service catalog enhancement (in flight) — long-term system of + record for `spec.taxonomy` authoring. From 0d5377c58c1057a13ff2608b8e870fe5e1fb1d95 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Fri, 22 May 2026 17:57:55 +0100 Subject: [PATCH 3/3] feat: add billing ActivityPolicies for BillingAccount + PaymentMethod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface user-facing billing API operations in the activity timeline so organisation operators can see who created billing accounts, attached projects, and added or removed payment methods. Follows the existing service.activity convention (resourcemanager, iam, notification, ...): - billing/billingaccount-policy.yaml — auditRules tiered on contactInfo. businessName, contactInfo.name, then the resource name so create entries prefer the human-recognisable label without breaking when the audit record lacks a requestObject (deletes). - billing/billingaccountbinding-policy.yaml — summarises bindings using the projectRef + billingAccountRef names so the timeline is meaningful even when the binding's own metadata.name is generated. - billing/paymentmethod-policy.yaml — surfaces the user-driven add / remove path on a PaymentMethod. The provider controller owns the asynchronous attach phase and writes via the system: user, which the shared exclusion filter drops, so the timeline stays focused on user actions and not status churn. Excluded from this pass: - PaymentMethodClass — cluster-scoped, operator-managed via Git; no user audit trail to surface. - MeterDefinition, MonitoredResourceType — admin-managed. - stripe.billing.miloapis.com/{StripeProviderConfig, StripePaymentMethod} — controller-owned, written exclusively under the system: actor and consumed by billing's user-facing PaymentMethod rather than directly by humans. --- .../billing/billingaccount-policy.yaml | 44 +++++++++++++++++++ .../billing/billingaccountbinding-policy.yaml | 34 ++++++++++++++ .../policies/billing/kustomization.yaml | 9 ++++ .../billing/paymentmethod-policy.yaml | 38 ++++++++++++++++ .../activity/policies/kustomization.yaml | 1 + 5 files changed, 126 insertions(+) create mode 100644 config/services/activity/policies/billing/billingaccount-policy.yaml create mode 100644 config/services/activity/policies/billing/billingaccountbinding-policy.yaml create mode 100644 config/services/activity/policies/billing/kustomization.yaml create mode 100644 config/services/activity/policies/billing/paymentmethod-policy.yaml diff --git a/config/services/activity/policies/billing/billingaccount-policy.yaml b/config/services/activity/policies/billing/billingaccount-policy.yaml new file mode 100644 index 00000000..f5aa1272 --- /dev/null +++ b/config/services/activity/policies/billing/billingaccount-policy.yaml @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-only + +# ActivityPolicy for BillingAccount resources. +# Defines how BillingAccount API operations appear in activity timelines. +# +# Audit rules handle CRUD operations captured by the Kubernetes API server audit log. +# No eventRules — the controller sets conditions but does not emit Kubernetes Events. +# +# Design principles: +# - Prefer contactInfo.businessName / contactInfo.name as the display label, +# falling back to the BillingAccount metadata name. The has() guards mean +# create entries surface the human-recognisable label while delete entries +# (which carry no requestObject) cleanly degrade to the resource name. +# - Action-oriented language ("created billing account", ...) +# - Exclude system actors so controller reconciliation does not generate noise. +apiVersion: activity.miloapis.com/v1alpha1 +kind: ActivityPolicy +metadata: + name: billing.miloapis.com-billingaccount +spec: + resource: + apiGroup: billing.miloapis.com + kind: BillingAccount + + auditRules: + - name: create-with-business-name + match: "!audit.user.username.startsWith('system:') && audit.verb == 'create' && has(audit.requestObject.spec) && has(audit.requestObject.spec.contactInfo) && has(audit.requestObject.spec.contactInfo.businessName)" + summary: "{{ actor }} created billing account {{ audit.requestObject.spec.contactInfo.businessName }}" + + - name: create-with-contact-name + match: "!audit.user.username.startsWith('system:') && audit.verb == 'create' && has(audit.requestObject.spec) && has(audit.requestObject.spec.contactInfo) && has(audit.requestObject.spec.contactInfo.name)" + summary: "{{ actor }} created billing account for {{ audit.requestObject.spec.contactInfo.name }}" + + - name: create-fallback + match: "!audit.user.username.startsWith('system:') && audit.verb == 'create'" + summary: "{{ actor }} created billing account {{ link(audit.objectRef.name, audit.objectRef) }}" + + - name: delete + match: "!audit.user.username.startsWith('system:') && audit.verb == 'delete'" + summary: "{{ actor }} deleted billing account {{ audit.objectRef.name }}" + + - name: update + match: "!audit.user.username.startsWith('system:') && audit.verb in ['update', 'patch'] && !has(audit.objectRef.subresource)" + summary: "{{ actor }} updated billing account {{ link(audit.objectRef.name, audit.objectRef) }}" diff --git a/config/services/activity/policies/billing/billingaccountbinding-policy.yaml b/config/services/activity/policies/billing/billingaccountbinding-policy.yaml new file mode 100644 index 00000000..9256236f --- /dev/null +++ b/config/services/activity/policies/billing/billingaccountbinding-policy.yaml @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: AGPL-3.0-only + +# ActivityPolicy for BillingAccountBinding resources. +# A BillingAccountBinding attaches a Project to a BillingAccount; surfacing +# create/delete events lets organisation operators see when projects are +# rerouted to a different billing account. +# +# Audit rules handle CRUD operations captured by the Kubernetes API server audit log. +# No eventRules — the controller sets conditions but does not emit Kubernetes Events. +apiVersion: activity.miloapis.com/v1alpha1 +kind: ActivityPolicy +metadata: + name: billing.miloapis.com-billingaccountbinding +spec: + resource: + apiGroup: billing.miloapis.com + kind: BillingAccountBinding + + auditRules: + - name: create-with-refs + match: "!audit.user.username.startsWith('system:') && audit.verb == 'create' && has(audit.requestObject.spec) && has(audit.requestObject.spec.projectRef) && has(audit.requestObject.spec.billingAccountRef)" + summary: "{{ actor }} bound project {{ audit.requestObject.spec.projectRef.name }} to billing account {{ audit.requestObject.spec.billingAccountRef.name }}" + + - name: create-fallback + match: "!audit.user.username.startsWith('system:') && audit.verb == 'create'" + summary: "{{ actor }} created billing account binding {{ link(audit.objectRef.name, audit.objectRef) }}" + + - name: delete + match: "!audit.user.username.startsWith('system:') && audit.verb == 'delete'" + summary: "{{ actor }} deleted billing account binding {{ audit.objectRef.name }}" + + - name: update + match: "!audit.user.username.startsWith('system:') && audit.verb in ['update', 'patch'] && !has(audit.objectRef.subresource)" + summary: "{{ actor }} updated billing account binding {{ link(audit.objectRef.name, audit.objectRef) }}" diff --git a/config/services/activity/policies/billing/kustomization.yaml b/config/services/activity/policies/billing/kustomization.yaml new file mode 100644 index 00000000..dcbc2e14 --- /dev/null +++ b/config/services/activity/policies/billing/kustomization.yaml @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: AGPL-3.0-only + +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component + +resources: + - billingaccount-policy.yaml + - billingaccountbinding-policy.yaml + - paymentmethod-policy.yaml diff --git a/config/services/activity/policies/billing/paymentmethod-policy.yaml b/config/services/activity/policies/billing/paymentmethod-policy.yaml new file mode 100644 index 00000000..1a841ce0 --- /dev/null +++ b/config/services/activity/policies/billing/paymentmethod-policy.yaml @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: AGPL-3.0-only + +# ActivityPolicy for PaymentMethod resources. +# A PaymentMethod represents a single tokenized payment instrument owned by a +# BillingAccount. Card details (brand, last4, etc.) only exist on .status +# after the provider controller has attached the upstream PaymentMethod, and +# audit events for updates are dominated by controller status writes — +# the user-visible mutations are create (add a card) and delete (remove it). +# +# Audit rules handle CRUD operations captured by the Kubernetes API server audit log. +# eventRules surface the provider controller's phase transition Events +# (PaymentMethodAttached, PaymentMethodFailed) so the timeline reflects the +# asynchronous outcome of card collection without leaking SDK detail. +apiVersion: activity.miloapis.com/v1alpha1 +kind: ActivityPolicy +metadata: + name: billing.miloapis.com-paymentmethod +spec: + resource: + apiGroup: billing.miloapis.com + kind: PaymentMethod + + auditRules: + - name: create-with-display-name + match: "!audit.user.username.startsWith('system:') && audit.verb == 'create' && has(audit.requestObject.spec) && has(audit.requestObject.spec.displayName)" + summary: "{{ actor }} added payment method {{ audit.requestObject.spec.displayName }}" + + - name: create-fallback + match: "!audit.user.username.startsWith('system:') && audit.verb == 'create'" + summary: "{{ actor }} added payment method {{ link(audit.objectRef.name, audit.objectRef) }}" + + - name: delete + match: "!audit.user.username.startsWith('system:') && audit.verb == 'delete'" + summary: "{{ actor }} removed payment method {{ audit.objectRef.name }}" + + - name: update + match: "!audit.user.username.startsWith('system:') && audit.verb in ['update', 'patch'] && !has(audit.objectRef.subresource)" + summary: "{{ actor }} updated payment method {{ link(audit.objectRef.name, audit.objectRef) }}" diff --git a/config/services/activity/policies/kustomization.yaml b/config/services/activity/policies/kustomization.yaml index b1bb7230..80d60dbc 100644 --- a/config/services/activity/policies/kustomization.yaml +++ b/config/services/activity/policies/kustomization.yaml @@ -4,6 +4,7 @@ apiVersion: kustomize.config.k8s.io/v1alpha1 kind: Component components: + - billing - iam - resourcemanager - identity-provider