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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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=<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.
Expand Down
29 changes: 12 additions & 17 deletions cmd/controlcenter/cc/assets/templates/idp_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,13 @@ <h1 class="cc-page-title">Create {{ .Kind }}</h1>

{{ if eq .InputType "checkbox" }}
<div class="idp-checkbox-row">
<input type="checkbox" id="field-{{ .Name }}" name="{{ .Name }}" data-type="boolean" data-source="{{ .Source }}"
<input type="checkbox" id="field-{{ .Name }}" name="{{ .Name }}" data-type="boolean"
{{ if .Disabled }}disabled{{ end }}>
<label for="field-{{ .Name }}">{{ .Label }}{{ if .Required }} <span style="color:var(--text-muted)">*</span>{{ end }}</label>
</div>
{{ else if eq .InputType "select" }}
<label for="field-{{ .Name }}">{{ .Label }}{{ if .Required }} <span style="color:var(--text-muted)">*</span>{{ end }}</label>
<select id="field-{{ .Name }}" name="{{ .Name }}" data-type="string" data-source="{{ .Source }}"
<select id="field-{{ .Name }}" name="{{ .Name }}" data-type="string"
{{ if and .Required (not .Disabled) }}required{{ end }}
{{ if .Disabled }}disabled{{ end }}>
<option value="">— select —</option>
Expand All @@ -249,16 +249,14 @@ <h1 class="cc-page-title">Create {{ .Kind }}</h1>
</select>
{{ else if eq .InputType "number" }}
<label for="field-{{ .Name }}">{{ .Label }}{{ if .Required }} <span style="color:var(--text-muted)">*</span>{{ end }}</label>
<input type="number" id="field-{{ .Name }}" name="{{ .Name }}" data-type="number" data-source="{{ .Source }}"
<input type="number" id="field-{{ .Name }}" name="{{ .Name }}" data-type="number"
{{ if .Placeholder }}placeholder="{{ .Placeholder }}"{{ end }}
{{ if .Default }}value="{{ .Default }}"{{ end }}
{{ if and .Required (not .Disabled) }}required{{ end }}
{{ if .Disabled }}disabled{{ end }}>
{{ else }}
<label for="field-{{ .Name }}">{{ .Label }}{{ if .Required }} <span style="color:var(--text-muted)">*</span>{{ end }}</label>
<input type="text" id="field-{{ .Name }}" name="{{ .Name }}" data-type="string" data-source="{{ .Source }}"
<input type="text" id="field-{{ .Name }}" name="{{ .Name }}" data-type="string"
{{ if .Placeholder }}placeholder="{{ .Placeholder }}"{{ end }}
{{ if .Default }}value="{{ .Default }}"{{ end }}
{{ if and .Required (not .Disabled) }}required{{ end }}
{{ if .Disabled }}disabled{{ end }}>
{{ end }}
Expand Down Expand Up @@ -293,6 +291,7 @@ <h1 class="cc-page-title">Create {{ .Kind }}</h1>
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) {
Expand Down Expand Up @@ -422,13 +421,11 @@ <h1 class="cc-page-title">Create {{ .Kind }}</h1>
}

// ── 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;
Expand All @@ -437,16 +434,14 @@ <h1 class="cc-page-title">Create {{ .Kind }}</h1>
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 ────────────────────────────────────────────────────
Expand Down
Loading
Loading