diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e5d75c9..7d7482c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -351,13 +351,15 @@ idp: name: '{{ repoSlug .spec.repository }}' ``` -`idp.name` is a template expression the gateway's Apply API (`POST /api/v1/apply`) resolves server-side, against exactly what the caller submitted, and always wins over whatever (if anything) the caller sent. When *not* set, a name is required from the caller — the Apply API now rejects a request with an empty `metadata.name` immediately, as a structured violation (`metadata.name is required`), instead of letting the SSA patch fail with a raw Kubernetes error. +`idp.name` is a template expression the gateway's Apply API resolves server-side, against exactly what the caller submitted, and always wins over whatever (if anything) the caller sent — the same in full CR mode (`POST /api/v1/apply` with a complete CR) and target mode (`{"target": ..., ...fields}`). When *not* set, a name is required from the caller instead — `metadata.name` in full CR mode, a flat `"name"` field in target mode — and the Apply API rejects a request with an empty one immediately, as a structured violation (`metadata.name is required`), instead of letting the SSA patch fail with a raw Kubernetes error. `CRDEntry.RequireIDPName()` (`true` unless `idp.name` is declared) flows through the runtime's `/katalog` response as `requireIdpName` and into the Control Center's IDP form — the Name field is only rendered when `requireIdpName` is true. +→ [Target Mode — `idp.name` and `idp.namespace`](./documentation/concepts/idp/02-target-mode.md#idpname-and-idpnamespace) + ### `idp.namespace` — server-side namespace resolution for the Apply API -A namespaced CRD needs a namespace on every CR it creates — but a browser form or a CI `curl` has no business deciding which one. `idp.namespace` works the same way as `idp.name` above — a template expression the gateway's Apply API resolves server-side against exactly what the caller submitted, always winning over whatever (if anything) the caller sent — but only applies to namespaced CRDs, and unlike `idp.name`, is required rather than optional: +A namespaced CRD needs a namespace on every CR it creates — but a browser form or a CI `curl` has no business deciding which one. `idp.namespace` works the same way as `idp.name` above — a template expression the gateway's Apply API resolves server-side against exactly what the caller submitted, always winning over whatever (if anything) the caller sent, the same in full CR mode and target mode — but only applies to namespaced CRDs, and unlike `idp.name`, is required rather than optional: ```yaml idp: @@ -385,6 +387,18 @@ idp: → [Nested fields with `path` reference](./documentation/reference/schema/02-katalog/21-idp-nested-spec.md) +### Control Center: the `[+ Create]` form now uses target mode + +The IDP create form built a full Kubernetes CR client-side — the browser knew which submitted field belonged in `spec` versus `metadata.labels`/`metadata.annotations`, and the server reassembled `apiVersion`/`kind`/`metadata`/`spec` before forwarding to the gateway. It now submits the same flat `{"target": "...", ...fields}` shape any other caller would, and the gateway builds the CR — Control Center no longer constructs one. The schema fetch that feeds the form moved with it: `GET /api/v1/schema/{kind}` (a path shape the gateway never actually served — this was silently 404ing) became `GET /api/v1/schema?target=`. The runtime's `/katalog` response now carries a `target` field per CRD alongside `idpEnabled`, so Control Center never has to derive one from `Kind`/GVK. + +### Fix: `idp.allowedTokens` warnings were never surfaced + +Three validation warnings — a token's `global` permissions containing an operation invalid for schema endpoints, a token with no permissions declared, namespace restrictions on a cluster-scoped CRD — were attached to a loop-local copy of the CRD entry and never written back to the Katalog, so they silently never appeared anywhere. Fixed by writing the mutated entry back to the Katalog's CRD map after each warning. + +### New: `idp.allowedTokens` security page + +`idp.allowedTokens` is a real, separate authorization layer — per-token operation and namespace scoping on the Apply API — not a variant of the existing CRD-level `allowedNamespaces`/`restrictedNamespaces` (which governs informer/admission topology the same way for every caller). Documented at [security/idp-permissions](./documentation/security/08-idp-permissions.md). + ### `POST /api/v1/apply` response: `pollUrl` replaces `resourceVersion` A successful apply now returns `pollUrl` — the exact `GET /api/v1/resources/{kind}/{namespace}/{name}` path for the CR just applied — instead of `resourceVersion`, which nothing consumed. Callers can `jq -r '.pollUrl'` straight into a poll loop instead of hand-assembling the path from `kind`/`namespace`/`name`. Cluster-scoped CRDs get an empty namespace segment (`/api/v1/resources/AppRequest//payments-api`), matching the existing `GET`/`DELETE` path convention. diff --git a/cmd/controlcenter/cc/assets/templates/idp_form.html b/cmd/controlcenter/cc/assets/templates/idp_form.html index 5d8c11f89..b11946bda 100644 --- a/cmd/controlcenter/cc/assets/templates/idp_form.html +++ b/cmd/controlcenter/cc/assets/templates/idp_form.html @@ -233,13 +233,13 @@

Create {{ .Kind }}

{{ if eq .InputType "checkbox" }}
-
{{ else if eq .InputType "select" }} - {{ else if eq .InputType "number" }} - {{ else }} - {{ end }} @@ -293,6 +291,7 @@

Create {{ .Kind }}

var btnPreview = document.getElementById('idp-preview'); var status = document.getElementById('idp-status'); var backURL = {{ .BackURL | js }}; + var target = {{ .Target | js }}; // ── Condition evaluation ──────────────────────────────────────────────── function evalCond(cond) { @@ -422,13 +421,11 @@

Create {{ .Kind }}

} // ── Payload collection ────────────────────────────────────────────────── - // Each field's data-source ("spec" | "label" | "annotation") decides which - // bucket it lands in — the server writes each bucket to a different part - // of the CR (spec.*, metadata.labels, metadata.annotations). Labels and - // annotations are always strings, regardless of data-type, since that's - // all Kubernetes metadata values can be. + // Flat target-mode payload — the gateway resolves which field goes where + // (spec, label, annotation) from the Katalog's idp.fields declaration; + // the form just submits the field names and values it was given. function collectPayload() { - var spec = {}, labels = {}, annotations = {}, name = ''; + var payload = { target: target }, name = ''; form.querySelectorAll('input, select').forEach(function (el) { var n = el.name; if (!n) return; @@ -437,16 +434,14 @@

Create {{ .Kind }}

if (wrap && wrap.style.display === 'none') return; if (el.disabled) return; var t = el.getAttribute('data-type'); - var source = el.getAttribute('data-source') || 'spec'; var v = (t === 'boolean') ? el.checked : (t === 'number') ? (el.value !== '' ? Number(el.value) : undefined) : el.value; if (v === undefined || v === '') return; - if (source === 'label') { labels[n] = String(v); } - else if (source === 'annotation') { annotations[n] = String(v); } - else { spec[n] = v; } + payload[n] = v; }); - return { name: name, spec: spec, labels: labels, annotations: annotations }; + if (name) payload.name = name; + return payload; } // ── Submit / Preview ──────────────────────────────────────────────────── diff --git a/cmd/controlcenter/cc/controlcenter.go b/cmd/controlcenter/cc/controlcenter.go index cd130de1b..11f8e9f74 100644 --- a/cmd/controlcenter/cc/controlcenter.go +++ b/cmd/controlcenter/cc/controlcenter.go @@ -324,8 +324,8 @@ func (cc *ControlCenter) ServeHTTP(w http.ResponseWriter, r *http.Request) { cc.handleIDPApply(w, r) case strings.HasPrefix(path, "/api/idp/schema/"): - kind := strings.TrimPrefix(path, "/api/idp/schema/") - cc.handleIDPSchema(w, r, kind) + target := strings.TrimPrefix(path, "/api/idp/schema/") + cc.handleIDPSchema(w, r, target) case strings.HasPrefix(path, "/katalog/"): // Strip leading slash and split @@ -874,15 +874,15 @@ func (cc *ControlCenter) handleCRList(w http.ResponseWriter, r *http.Request, in }) } -// handleIDPSchema proxies GET /api/v1/schema/{kind} from the gateway. +// handleIDPSchema proxies GET /api/v1/schema?target= from the gateway. // The gateway token is stored server-side — the browser never sees it. -func (cc *ControlCenter) handleIDPSchema(w http.ResponseWriter, r *http.Request, kind string) { +func (cc *ControlCenter) handleIDPSchema(w http.ResponseWriter, r *http.Request, target string) { inst := cc.firstInstance() if inst == nil || inst.GatewayEndpoint == "" { http.Error(w, `{"error":"no gateway configured"}`, http.StatusServiceUnavailable) return } - cc.proxyIDPRequest(w, r, inst.GatewayEndpoint+"/api/v1/schema/"+kind, http.MethodGet, nil) + cc.proxyIDPRequest(w, r, inst.GatewayEndpoint+"/api/v1/schema?target="+url.QueryEscape(target), http.MethodGet, nil) } // handleIDPApply proxies POST /api/v1/apply to the gateway. @@ -963,22 +963,26 @@ func (cc *ControlCenter) handleIDPCreateForm(w http.ResponseWriter, r *http.Requ break } } - if crdSummary == nil || !crdSummary.IdpEnabled { + if crdSummary == nil || !crdSummary.IdpEnabled || crdSummary.Target == "" { http.Redirect(w, r, backURL, http.StatusSeeOther) return } + target := crdSummary.Target + // Kind/APIVersion are display-only (page header) — the gateway builds + // the CR from target, it doesn't need them from the form. kind, apiVersion := idpParseGVK(crdSummary.GVK) if r.Method == http.MethodPost { - cc.handleIDPApplyForm(w, r, inst, kind, apiVersion) + cc.handleIDPApplyForm(w, r, inst, target) return } - sections, fetchErr := cc.fetchIDPFields(inst, crdName, kind) + sections, fetchErr := cc.fetchIDPFields(inst, target) data := IDPFormData{ KatalogName: katalogName, CRDName: crdName, + Target: target, Kind: kind, APIVersion: apiVersion, BackURL: backURL, @@ -1004,11 +1008,10 @@ const ( FieldTypeEnum FieldType = "enum" ) -// idpFieldHint mirrors orktypes.IDPFieldConfig — the presentation-hint shape -// shared by idp.fields, idp.additionalFields.labels, and -// idp.additionalFields.annotations. Type/Enum only matter for the latter -// two: spec fields always infer type/enum from the CRD's OpenAPI schema -// (schemaResp.Properties) instead. +// idpFieldHint mirrors one entry of pkg/gateway/applyapi.SchemaResponse.Fields +// (itself orktypes.IDPFieldConfig) — the gateway's flat, caller-facing field +// contract. It doesn't distinguish where a field routes to (spec, label, +// annotation) — the gateway resolves that from the Katalog, not the caller. type idpFieldHint struct { Label string `json:"label"` Placeholder string `json:"placeholder"` @@ -1023,76 +1026,17 @@ type idpFieldHint struct { Enum []string `json:"enum"` } -// idpSchemaProperty mirrors one entry of pkg/gateway/applyapi.SchemaResponse.Properties -// — a CRD spec field as reported by the CRD's own OpenAPI schema. Type is always a JSON -// Schema base type ("string", "integer", "number", "boolean") — CRD schemas never use -// "enum" as a type; Enum is a constraint alongside "string", not a type of its own. -type idpSchemaProperty struct { - Type string `json:"type"` - Enum []string `json:"enum"` - Description string `json:"description"` - Default interface{} `json:"default"` -} - -// buildSpecIDPField builds an IDPField for one idp.fields entry (or an -// undeclared spec property with no hint). Unlike additionalFields, -// type/enum come from the CRD's OpenAPI schema (prop), not from the hint — -// a schema enum is reported as Type: "string" with Enum populated, never -// Type: "enum". -func buildSpecIDPField(name string, prop idpSchemaProperty, hint idpFieldHint, required bool) IDPField { - label := hint.Label - if label == "" { - label = strings.ToUpper(name[:1]) + name[1:] - } - f := IDPField{ - Name: name, - Label: label, - Hint: hint.Hint, - Placeholder: hint.Placeholder, - Required: required || hint.Required, - Category: hint.Category, - Disabled: hint.Disabled, - Source: IDPFieldSourceSpec, - } - if f.Hint == "" { - f.Hint = prop.Description - } - // Pre-populate from CRD schema default: - if prop.Default != nil { - f.Default = fmt.Sprintf("%v", prop.Default) - } - // Encode when/anyOf as JSON strings for the template to embed as data attributes. - if len(hint.When) > 0 { - if b, err := json.Marshal(hint.When); err == nil { - f.WhenJSON = string(b) - } - } - if len(hint.AnyOf) > 0 { - if b, err := json.Marshal(hint.AnyOf); err == nil { - f.AnyOfJSON = string(b) - } - } - switch { - case len(prop.Enum) > 0: - f.InputType = "select" - f.Enum = prop.Enum - case prop.Type == string(FieldTypeBoolean): - f.InputType = "checkbox" - case prop.Type == string(FieldTypeInteger) || prop.Type == string(FieldTypeNumber): - f.InputType = "number" - default: - f.InputType = "text" - } - return f -} - -// buildAdditionalIDPField builds an IDPField for one idp.additionalFields -// entry. Unlike spec fields, type/enum come from the hint itself — there's -// no CRD schema to infer them from. -func buildAdditionalIDPField(name string, hint idpFieldHint, source string) IDPField { +// buildIDPField builds an IDPField for one gateway schema field entry. +// Type/enum come from the field's own declared type — the gateway's flat +// schema doesn't distinguish where a field routes to (spec, label, +// annotation), so neither does the form. +func buildIDPField(name string, hint idpFieldHint) IDPField { label := hint.Label if label == "" { label = name + if len(label) > 0 { + label = strings.ToUpper(label[:1]) + label[1:] + } } f := IDPField{ Name: name, @@ -1102,8 +1046,8 @@ func buildAdditionalIDPField(name string, hint idpFieldHint, source string) IDPF Required: hint.Required, Category: hint.Category, Disabled: hint.Disabled, - Source: source, } + // Encode when/anyOf as JSON strings for the template to embed as data attributes. if len(hint.When) > 0 { if b, err := json.Marshal(hint.When); err == nil { f.WhenJSON = string(b) @@ -1114,7 +1058,6 @@ func buildAdditionalIDPField(name string, hint idpFieldHint, source string) IDPF f.AnyOfJSON = string(b) } } - switch { case hint.Type == string(FieldTypeEnum) && len(hint.Enum) > 0: f.InputType = "select" @@ -1129,10 +1072,8 @@ func buildAdditionalIDPField(name string, hint idpFieldHint, source string) IDPF return f } -// fetchIDPFields fetches the CRD schema from the gateway. The gateway merges -// the OpenAPI spec properties with idp.fields hints (labels, hints, order) -// from the Katalog, so a single call is enough. -func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([]IDPSection, error) { +// fetchIDPFields fetches the flat field schema for target from the gateway. +func (cc *ControlCenter) fetchIDPFields(inst *Instance, target string) ([]IDPSection, error) { if inst.GatewayEndpoint == "" { return nil, fmt.Errorf("no gateway endpoint configured") } @@ -1140,8 +1081,7 @@ func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([ return nil, fmt.Errorf("GATEWAY_TOKEN not set") } - // ── Schema + idpFields from a single gateway call ──────────────────────── - req, _ := http.NewRequest(http.MethodGet, inst.GatewayEndpoint+"/api/v1/schema/"+strings.ToLower(kind), nil) //nolint:noctx + req, _ := http.NewRequest(http.MethodGet, inst.GatewayEndpoint+"/api/v1/schema?target="+url.QueryEscape(target), nil) //nolint:noctx req.Header.Set("Authorization", "Bearer "+cc.gatewayToken) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) @@ -1154,56 +1094,22 @@ func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([ return nil, fmt.Errorf("%s", strings.TrimSpace(string(body))) } - // SchemaResponse mirrors pkg/gateway/schema.SchemaResponse. + // schemaResp mirrors pkg/gateway/applyapi.SchemaResponse — a flat field + // map, no CRD-schema/bucket distinction. var schemaResp struct { - Properties map[string]idpSchemaProperty `json:"properties"` - Required []string `json:"required"` - IgnoreFields []string `json:"ignoreFields"` - IDPFields map[string]idpFieldHint `json:"idpFields"` - AdditionalLabels map[string]idpFieldHint `json:"additionalLabels"` - AdditionalAnnotations map[string]idpFieldHint `json:"additionalAnnotations"` + Fields map[string]idpFieldHint `json:"fields"` } if err := json.NewDecoder(resp.Body).Decode(&schemaResp); err != nil { return nil, err } - requiredSet := map[string]bool{} - for _, name := range schemaResp.Required { - requiredSet[name] = true - } - ignoreSet := map[string]bool{} - for _, name := range schemaResp.IgnoreFields { - ignoreSet[name] = true - } - type orderedField struct { field IDPField order int } var ordered []orderedField - - for name, prop := range schemaResp.Properties { - if ignoreSet[name] { - continue - } - hint := schemaResp.IDPFields[name] - f := buildSpecIDPField(name, prop, hint, requiredSet[name]) - ordered = append(ordered, orderedField{field: f, order: hint.Order}) - } - - // idp.additionalFields — labels and annotations. No CRD schema counterpart, - // so type/enum come from the hint itself instead of schemaResp.Properties. - for name, hint := range schemaResp.AdditionalLabels { - ordered = append(ordered, orderedField{ - field: buildAdditionalIDPField(name, hint, IDPFieldSourceLabel), - order: hint.Order, - }) - } - for name, hint := range schemaResp.AdditionalAnnotations { - ordered = append(ordered, orderedField{ - field: buildAdditionalIDPField(name, hint, IDPFieldSourceAnnotation), - order: hint.Order, - }) + for name, hint := range schemaResp.Fields { + ordered = append(ordered, orderedField{field: buildIDPField(name, hint), order: hint.Order}) } sort.Slice(ordered, func(i, j int) bool { @@ -1220,17 +1126,13 @@ func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([ } return ordered[i].field.Name < ordered[j].field.Name }) - // Group sorted fields into sections by Group name. + // Group sorted fields into sections by Category name. var sections []IDPSection for _, o := range ordered { f := o.field title := f.Category if title == "" { - if f.Source == "spec" { - title = "Spec" - } else { - title = "Additional Fields" - } + title = "Fields" } if len(sections) == 0 || sections[len(sections)-1].Title != title { sections = append(sections, IDPSection{Title: title}) @@ -1240,51 +1142,28 @@ func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([ return sections, nil } -// handleIDPApplyForm processes the IDP form POST: builds a CR and forwards to the gateway. -func (cc *ControlCenter) handleIDPApplyForm(w http.ResponseWriter, r *http.Request, inst *Instance, kind, apiVersion string) { - var body struct { - Name string `json:"name"` - Spec map[string]any `json:"spec"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` - } +// handleIDPApplyForm processes the IDP form POST: forwards the flat +// target-mode payload the form submitted to the gateway's Apply API. The +// gateway builds the CR from target — this handler doesn't construct one. +func (cc *ControlCenter) handleIDPApplyForm(w http.ResponseWriter, r *http.Request, inst *Instance, target string) { + var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `{"error":"invalid request body"}`) return } + // target is always the server-resolved value for this route, not + // whatever the client sent (there's no client-supplied target to trust + // here — the route itself determines which CRD this form is for). + body["target"] = target - // Namespace is never sent by the form — for a namespaced CRD it's - // resolved server-side by the Apply API from idp.namespace (which always - // wins over whatever a caller sends anyway); a cluster-scoped CRD has no - // namespace concept at all. See idp.namespace in the schema reference. - // - // body.Name is "" when idp.name is declared — the template omits the - // Name field entirely (see RequireIDPName), so there's nothing to collect. - // The Apply API resolves it server-side from idp.name the same way it - // resolves namespace. - metadata := map[string]any{ - "name": body.Name, - } - if len(body.Labels) > 0 { - metadata["labels"] = body.Labels - } - if len(body.Annotations) > 0 { - metadata["annotations"] = body.Annotations - } - cr := map[string]any{ - "apiVersion": apiVersion, - "kind": kind, - "metadata": metadata, - "spec": body.Spec, - } - crJSON, _ := json.Marshal(cr) + payload, _ := json.Marshal(body) applyURL := inst.GatewayEndpoint + "/api/v1/apply" if r.URL.Query().Get("dryRun") == "true" { applyURL += "?dryRun=true" } - cc.proxyIDPRequest(w, r, applyURL, http.MethodPost, bytes.NewReader(crJSON)) + cc.proxyIDPRequest(w, r, applyURL, http.MethodPost, bytes.NewReader(payload)) } // idpParseGVK splits "group/version, Kind=Kind" into (kind, apiVersion). diff --git a/cmd/controlcenter/cc/idp_fields_test.go b/cmd/controlcenter/cc/idp_fields_test.go index 35f134f35..f124e765f 100644 --- a/cmd/controlcenter/cc/idp_fields_test.go +++ b/cmd/controlcenter/cc/idp_fields_test.go @@ -5,43 +5,23 @@ import ( "testing" ) -// Regression test for the bug where every CRD-schema enum field silently -// rendered as a plain text box instead of a dropdown: buildSpecIDPField's -// switch once required prop.Type == "enum" before treating a field as a -// select, but CRD OpenAPI schemas never set type: enum — enum is always a -// constraint alongside type: string. The check must depend only on -// len(prop.Enum) > 0. -func TestBuildSpecIDPField_SchemaEnumRendersAsSelect(t *testing.T) { - prop := idpSchemaProperty{ - Type: "string", - Enum: []string{"app", "cert", "monitoring", "infra"}, - } - f := buildSpecIDPField("workloadType", prop, idpFieldHint{}, false) - - if f.InputType != "select" { - t.Fatalf("InputType = %q, want %q (prop.Type=%q with Enum set must still render as a dropdown)", f.InputType, "select", prop.Type) - } - if len(f.Enum) != 4 || f.Enum[0] != "app" { - t.Fatalf("Enum = %v, want the schema's enum list carried through", f.Enum) - } -} - -func TestBuildSpecIDPField_InputTypes(t *testing.T) { +func TestBuildIDPField_InputTypes(t *testing.T) { cases := []struct { name string - prop idpSchemaProperty + hint idpFieldHint want string }{ - {"enum wins even with a base type", idpSchemaProperty{Type: "string", Enum: []string{"a", "b"}}, "select"}, - {"boolean", idpSchemaProperty{Type: "boolean"}, "checkbox"}, - {"integer", idpSchemaProperty{Type: "integer"}, "number"}, - {"number", idpSchemaProperty{Type: "number"}, "number"}, - {"plain string", idpSchemaProperty{Type: "string"}, "text"}, - {"unknown/empty type", idpSchemaProperty{}, "text"}, + {"enum with values", idpFieldHint{Type: "enum", Enum: []string{"a", "b"}}, "select"}, + {"enum type but no values falls through", idpFieldHint{Type: "enum"}, "text"}, + {"boolean", idpFieldHint{Type: "boolean"}, "checkbox"}, + {"integer", idpFieldHint{Type: "integer"}, "number"}, + {"number", idpFieldHint{Type: "number"}, "number"}, + {"string", idpFieldHint{Type: "string"}, "text"}, + {"type omitted defaults to text", idpFieldHint{}, "text"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - f := buildSpecIDPField("field", c.prop, idpFieldHint{}, false) + f := buildIDPField("field", c.hint) if f.InputType != c.want { t.Errorf("InputType = %q, want %q", f.InputType, c.want) } @@ -49,68 +29,36 @@ func TestBuildSpecIDPField_InputTypes(t *testing.T) { } } -func TestBuildSpecIDPField_LabelFallback(t *testing.T) { - f := buildSpecIDPField("workloadType", idpSchemaProperty{}, idpFieldHint{}, false) +func TestBuildIDPField_LabelFallback(t *testing.T) { + f := buildIDPField("workloadType", idpFieldHint{}) if f.Label != "WorkloadType" { t.Errorf("Label = %q, want auto-capitalized field name %q", f.Label, "WorkloadType") } - f = buildSpecIDPField("workloadType", idpSchemaProperty{}, idpFieldHint{Label: "Workload Type"}, false) + f = buildIDPField("workloadType", idpFieldHint{Label: "Workload Type"}) if f.Label != "Workload Type" { t.Errorf("Label = %q, want hint.Label to override the fallback", f.Label) } } -func TestBuildSpecIDPField_HintFallsBackToSchemaDescription(t *testing.T) { - prop := idpSchemaProperty{Description: "Team that owns this resource"} - - f := buildSpecIDPField("team", prop, idpFieldHint{}, false) - if f.Hint != prop.Description { - t.Errorf("Hint = %q, want schema Description %q when hint.Hint is empty", f.Hint, prop.Description) - } - - f = buildSpecIDPField("team", prop, idpFieldHint{Hint: "custom hint"}, false) - if f.Hint != "custom hint" { - t.Errorf("Hint = %q, want hint.Hint to take precedence over schema Description", f.Hint) - } -} - -func TestBuildSpecIDPField_Required(t *testing.T) { - // required via the CRD schema's required: list - f := buildSpecIDPField("team", idpSchemaProperty{}, idpFieldHint{}, true) - if !f.Required { - t.Error("Required = false, want true when the schema marks the field required") - } - - // required via idp.fields..required, independent of the schema - f = buildSpecIDPField("team", idpSchemaProperty{}, idpFieldHint{Required: true}, false) +func TestBuildIDPField_Required(t *testing.T) { + f := buildIDPField("team", idpFieldHint{Required: true}) if !f.Required { t.Error("Required = false, want true when hint.Required is set") } - f = buildSpecIDPField("team", idpSchemaProperty{}, idpFieldHint{}, false) + f = buildIDPField("team", idpFieldHint{}) if f.Required { - t.Error("Required = true, want false when neither source marks it required") + t.Error("Required = true, want false when hint.Required is unset") } } -func TestBuildSpecIDPField_DefaultAndSource(t *testing.T) { - prop := idpSchemaProperty{Default: 3} - f := buildSpecIDPField("replicas", prop, idpFieldHint{}, false) - if f.Default != "3" { - t.Errorf("Default = %q, want %q", f.Default, "3") - } - if f.Source != IDPFieldSourceSpec { - t.Errorf("Source = %q, want %q", f.Source, IDPFieldSourceSpec) - } -} - -func TestBuildSpecIDPField_WhenAnyOfEncodedAsJSON(t *testing.T) { +func TestBuildIDPField_WhenAnyOfEncodedAsJSON(t *testing.T) { hint := idpFieldHint{ When: []json.RawMessage{json.RawMessage(`{"field":"spec.workloadType","equals":"app"}`)}, AnyOf: []json.RawMessage{json.RawMessage(`{"field":"spec.workloadType","equals":"cert"}`)}, } - f := buildSpecIDPField("repoURL", idpSchemaProperty{}, hint, false) + f := buildIDPField("repoURL", hint) var when []map[string]string if err := json.Unmarshal([]byte(f.WhenJSON), &when); err != nil { @@ -129,53 +77,8 @@ func TestBuildSpecIDPField_WhenAnyOfEncodedAsJSON(t *testing.T) { } } -func TestBuildAdditionalIDPField_InputTypes(t *testing.T) { - cases := []struct { - name string - hint idpFieldHint - want string - }{ - {"enum with values", idpFieldHint{Type: "enum", Enum: []string{"a", "b"}}, "select"}, - {"enum type but no values falls through", idpFieldHint{Type: "enum"}, "text"}, - {"boolean", idpFieldHint{Type: "boolean"}, "checkbox"}, - {"integer", idpFieldHint{Type: "integer"}, "number"}, - {"number", idpFieldHint{Type: "number"}, "number"}, - {"string", idpFieldHint{Type: "string"}, "text"}, - {"type omitted defaults to text", idpFieldHint{}, "text"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - f := buildAdditionalIDPField("field", c.hint, IDPFieldSourceLabel) - if f.InputType != c.want { - t.Errorf("InputType = %q, want %q", f.InputType, c.want) - } - }) - } -} - -func TestBuildAdditionalIDPField_LabelFallbackAndSource(t *testing.T) { - f := buildAdditionalIDPField("team", idpFieldHint{}, IDPFieldSourceLabel) - if f.Label != "team" { - t.Errorf("Label = %q, want the raw key %q (additionalFields keys are not auto-capitalized)", f.Label, "team") - } - if f.Source != IDPFieldSourceLabel { - t.Errorf("Source = %q, want %q", f.Source, IDPFieldSourceLabel) - } - - f = buildAdditionalIDPField("platform.myorg.io/expose", idpFieldHint{Label: "Expose externally"}, IDPFieldSourceAnnotation) - if f.Label != "Expose externally" { - t.Errorf("Label = %q, want hint.Label to override the raw key", f.Label) - } - if f.Source != IDPFieldSourceAnnotation { - t.Errorf("Source = %q, want %q", f.Source, IDPFieldSourceAnnotation) - } -} - -func TestBuildAdditionalIDPField_RequiredCategoryDisabled(t *testing.T) { - f := buildAdditionalIDPField("team", idpFieldHint{Required: true, Category: "Ownership", Disabled: "locked for maintenance"}, IDPFieldSourceLabel) - if !f.Required { - t.Error("Required = false, want true") - } +func TestBuildIDPField_CategoryAndDisabled(t *testing.T) { + f := buildIDPField("team", idpFieldHint{Category: "Ownership", Disabled: "locked for maintenance"}) if f.Category != "Ownership" { t.Errorf("Category = %q, want %q", f.Category, "Ownership") } diff --git a/cmd/controlcenter/cc/types.go b/cmd/controlcenter/cc/types.go index 47c280666..2cc75ce9d 100644 --- a/cmd/controlcenter/cc/types.go +++ b/cmd/controlcenter/cc/types.go @@ -218,11 +218,15 @@ type EndpointInfo struct { // CRDSummary is a summary of a CRD type CRDSummary struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Mode string `json:"mode,omitempty"` - GVK string `json:"gvk,omitempty"` - GVR string `json:"gvr,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Mode string `json:"mode,omitempty"` + GVK string `json:"gvk,omitempty"` + GVR string `json:"gvr,omitempty"` + // Target is the identifier this CRD is addressed by in the Apply API and + // schema API (idp.target, or the lowercased kind when unset). Empty when + // IDP isn't enabled for this CRD. + Target string `json:"target,omitempty"` Namespaced bool `json:"namespaced"` Namespace string `json:"namespace,omitempty"` CrossAccess bool `json:"crossAccess"` @@ -249,15 +253,6 @@ type CRDSummary struct { RequireIDPName bool `json:"requireIdpName,omitempty"` } -// IDP field write targets — which part of the CR an IDPField's submitted -// value gets written to. Mirrors the bucketing in handleIDPApplyForm and the -// data-source attribute idp_form.html's collectPayload() reads client-side. -const ( - IDPFieldSourceSpec = "spec" - IDPFieldSourceLabel = "label" - IDPFieldSourceAnnotation = "annotation" -) - // IDPField is one rendered field in the IDP create form. type IDPField struct { Name string @@ -267,12 +262,10 @@ type IDPField struct { Hint string Enum []string Required bool - Default string // pre-populated value from CRD schema default: Category string // section heading for visual grouping WhenJSON string // JSON array of Condition — all must be true (AND) AnyOfJSON string // JSON array of Condition — at least one must be true (OR) Disabled string // non-empty → greyed-out field with this message - Source string // IDPFieldSourceSpec (default) | IDPFieldSourceLabel | IDPFieldSourceAnnotation } // IDPSection is a group of fields sharing a section heading in the IDP form. @@ -283,8 +276,13 @@ type IDPSection struct { // IDPFormData is the view model for idp_form.html. type IDPFormData struct { - KatalogName string - CRDName string + KatalogName string + CRDName string + // Target is the identifier submitted to the gateway's Apply API + // (idp.target, or the lowercased kind when unset). + Target string + // Kind/APIVersion are display-only — shown in the page header, not used + // to build the submitted payload (the gateway builds the CR). Kind string APIVersion string BackURL string diff --git a/cmd/controlcenter/docs/02-routing.md b/cmd/controlcenter/docs/02-routing.md index a11a211c5..395f0e51c 100644 --- a/cmd/controlcenter/docs/02-routing.md +++ b/cmd/controlcenter/docs/02-routing.md @@ -21,13 +21,14 @@ All requests arrive at `ControlCenter.ServeHTTP`, which strips the `/controlcent | `/katalog/{name}/crd/{crd}/raw` | `handleProxyCRDSpec` | — (JSON proxy) | | `/katalog/{name}/crd/{crd}/enriched` | `handleProxyCRDSpec` | — (JSON proxy) | | `/katalog/{name}/crd/{crd}/cr` | `handleCRList` | `cr_list.html` | +| `/katalog/{name}/crd/{crd}/cr/create` | `handleIDPCreateForm` (GET renders, POST applies) | `idp_form.html` | | `/katalog/{name}/crd/{crd}/cr/{crname}` | `handleCRDetail` (cluster-scoped) | `cr_detail.html` | | `/katalog/{name}/crd/{crd}/cr/{ns}/{crname}` | `handleCRDetail` (namespaced) | `cr_detail.html` | | `GET /api/instances` | `handleListInstances` | — (JSON) | | `POST /api/instances` | `handleAddInstance` | — (JSON) | | `PUT /api/instances/{url}` | `handleUpdateInstance` | — (JSON) | | `DELETE /api/instances/{url}` | `handleDeleteInstance` | — (JSON) | -| `GET /api/idp/schema/{kind}` | `handleIDPSchema` | — (JSON proxy) | +| `GET /api/idp/schema/{target}` | `handleIDPSchema` | — (JSON proxy) | | `POST /api/idp/apply` | `handleIDPApply` | — (JSON proxy) | ## Katalog sub-routing @@ -56,9 +57,9 @@ len(crParts) == 3 → namespaced CR detail (/cr/{ns}/{name}) ## IDP proxy endpoints -`/api/idp/schema/{kind}` and `/api/idp/apply` are server-side proxies to the companion gateway's Apply API. The CC holds the `GATEWAY_TOKEN` bearer token; the browser never sees it. All IDP form traffic flows through these two routes — schema fetch and CR apply — so the gateway can be behind a different origin without CORS or token-exposure issues. +`/api/idp/schema/{target}` and `/api/idp/apply` are standalone server-side proxies to the companion gateway's Apply API, for callers that want the gateway's raw JSON directly rather than the rendered form. The CC holds the `GATEWAY_TOKEN` bearer token; the browser never sees it. The `[+ Create]` form itself doesn't use these — see [06-idp-form.md](06-idp-form.md) for its actual request flow (`handleIDPCreateForm`/`fetchIDPFields`/`handleIDPApplyForm`). -`handleIDPSchema` forwards `GET {gatewayEndpoint}/api/v1/schema/{kind}`. `handleIDPApply` forwards `POST {gatewayEndpoint}/api/v1/apply` with the request body unchanged. Both respond with the gateway's status code and JSON body verbatim. +`handleIDPSchema` forwards `GET {gatewayEndpoint}/api/v1/schema?target={target}`. `handleIDPApply` forwards `POST {gatewayEndpoint}/api/v1/apply` with the request body unchanged. Both respond with the gateway's status code and JSON body verbatim. ## Template rendering diff --git a/cmd/controlcenter/docs/03-data-flow.md b/cmd/controlcenter/docs/03-data-flow.md index cd8657f5a..d423c26a1 100644 --- a/cmd/controlcenter/docs/03-data-flow.md +++ b/cmd/controlcenter/docs/03-data-flow.md @@ -26,19 +26,20 @@ When a runtime advertises a companion gateway via `"gatewayEndpoint"` in its `/k | Endpoint | Go type | Used by | |----------|---------|---------| | `GET /katalog/{crd}` | `GatewayCRDStats` | `handleCRDDetail` — merges admission, conversion, deletion/namespace protection stats | -| `GET /api/v1/schema/{kind}` | raw JSON | `handleIDPSchema` — proxied to browser for IDP form rendering | -| `POST /api/v1/apply` | raw JSON | `handleIDPApply` — proxied from IDP form submit | +| `GET /api/v1/schema?target={target}` | `SchemaResponse` (flat field map) | `fetchIDPFields` (form render); `handleIDPSchema` proxy | +| `POST /api/v1/apply` | `ApplyResponse` | `handleIDPApplyForm` (form submit); `handleIDPApply` proxy | -The gateway URL is stored on `Instance.GatewayEndpoint` when the runtime katalog is fetched. Webhook stats are queried on-demand at CRD detail page load. IDP schema and apply calls are proxied on-demand from the browser via the CC's `/api/idp/` routes — the CC adds the `Authorization: Bearer` header so the token stays server-side. +The gateway URL is stored on `Instance.GatewayEndpoint` when the runtime katalog is fetched. Webhook stats are queried on-demand at CRD detail page load. Schema fetch and apply for the `[+ Create]` form happen server-side in `handleIDPCreateForm`/`handleIDPApplyForm` — the CC adds the `Authorization: Bearer` header so the token stays server-side. The `/api/idp/*` routes are a separate, standalone proxy pair for callers that want raw gateway JSON directly (see [02-routing.md](02-routing.md)). ## IDP mode -When the runtime includes `idpEnabled: true` on a `CRDSummary` entry in the `/katalog` response, the CR list page for that CRD renders a `[+ Create]` button. Clicking it fetches the CRD schema from the gateway (via `/api/idp/schema/{kind}`) and renders a form. On submit the CC posts to `/api/idp/apply` which forwards to the gateway's `POST /api/v1/apply`. +When the runtime includes `"idpEnabled": true` and a non-empty `"target"` on a `CRDSummaryResponse` entry in the `/katalog` response, the CR list page for that CRD renders a `[+ Create]` button linking to `/katalog/{kat}/crd/{crd}/cr/create`. That route fetches the flat field schema for `Target` (`GET /api/v1/schema?target=...`) and renders `idp_form.html`. On submit, the browser POSTs a flat `{"target": "...", ...fields}` object back to the same route; `handleIDPApplyForm` forwards it to the gateway's `POST /api/v1/apply`, which builds the CR. Control Center never constructs a CR itself — see [06-idp-form.md](06-idp-form.md). The `GATEWAY_TOKEN` env var on the CC is the bearer token sent to the gateway. The browser only ever talks to the CC — no cross-origin requests, no token exposure. ``` KatalogResponse.CRDs[].IdpEnabled ← per-CRD flag from runtime /katalog +KatalogResponse.CRDs[].Target ← idp.target (or lowercased kind); empty when IDP disabled KatalogResponse.GatewayEndpoint ← stored on Instance; used by CC proxy handlers ControlCenter.gatewayToken ← from GATEWAY_TOKEN env var; never sent to browser ``` diff --git a/cmd/controlcenter/docs/06-idp-form.md b/cmd/controlcenter/docs/06-idp-form.md index 3a28745cf..4bac19dc4 100644 --- a/cmd/controlcenter/docs/06-idp-form.md +++ b/cmd/controlcenter/docs/06-idp-form.md @@ -1,6 +1,8 @@ # 06 — IDP Self-Service Form -The IDP form is the browser-native delivery path for the Apply API. A platform team declares `idp.enabled: true` on a CRD entry; from that moment, any developer with access to the Control Center can create instances of that CRD by filling a form — no YAML, no `kubectl`, no cluster credentials. +The IDP form is the browser-native delivery path for the Apply API's target mode. A platform team declares `idp.enabled: true` and `idp.fields`/`idp.additionalFields` on a CRD entry; from that moment, any developer with access to the Control Center can create instances of that CRD by filling a form — no YAML, no `kubectl`, no cluster credentials, and no knowledge of the CRD's Kubernetes shape. + +Control Center never constructs a Kubernetes CR itself. It submits a flat `{"target": "", ...fields}` payload to the gateway's Apply API; the gateway (`pkg/gateway/applyapi`) resolves the target to a CRD and builds the CR via `BuildCRFromTarget`, using the CRD's `idp.fields`/`idp.additionalFields` declarations to route each field into `spec`, `metadata.labels`, or `metadata.annotations`, and `idp.name`/`idp.namespace` to resolve identity. ## Activation @@ -8,91 +10,80 @@ Three things must all be true for the `[+ Create]` button to appear: 1. `gateway.applyAPI.enabled: true` on the Katalog 2. `idp.enabled: true` on the CRD entry -3. `ORK_CC_APPLY_TOKEN` set on the CC process +3. `GATEWAY_TOKEN` set on the CC process -The runtime sets `CRDSummary.IDPEnabled` in its `/katalog` response (field: `"idpEnabled"`). The CC reads it during the background fetch and passes it to `CRListView.IDPEnabled`. The button is suppressed server-side when no Apply token is configured so the route is never advertised without auth. +The runtime sets `CRDSummaryResponse.IDPEnabled` and `CRDSummaryResponse.Target` in its `/katalog` response (`"idpEnabled"`, `"target"`). CC mirrors both onto `CRDSummary`. `Target` is the identifier CC submits to the gateway — it comes from `idp.target` if the platform team set one, otherwise the lowercased Kind — CC never derives it itself. `handleIDPCreateForm` redirects back if `IdpEnabled` is false or `Target` is empty (IDP not actually usable for this CRD). ## Request flow ``` -Browser GET /controlcenter/katalog/{kat}/crd/{crd}/idp +Browser GET /controlcenter/katalog/{kat}/crd/{crd}/cr/create │ - ├─ handleIDPCreate reads Instance.GatewayEndpoint - ├─ FetchIDPSchema → GET {gateway}/api/v1/schema/{kind} - │ Authorization: Bearer {ORK_CC_APPLY_TOKEN} - │ ← SchemaResponse { kind, apiVersion, properties, idpFields } + ├─ handleIDPCreateForm looks up CRDSummary.Target for this CRD + ├─ fetchIDPFields → GET {gateway}/api/v1/schema?target={target} + │ Authorization: Bearer {GATEWAY_TOKEN} + │ ← SchemaResponse { target, title, description, fields, required } + │ fields is a FLAT map[string]IDPFieldConfig — no spec/label/ + │ annotation distinction. The gateway resolves that at apply time + │ from the same idp.fields/idp.additionalFields declaration. │ - └─ renderTemplate("idp_form.html", IDPFormView) - Fields assembled by buildIDPFormFields: - OpenAPI property type → HTML input type - string → - string + enum → - boolean → - IDP hint.label →