Repository navigation
fix(api): verify service ownership and reserve platform app names - #551
Conversation
Tenant service operations build the Argo CD Application name as <team>-<component>, and create-tenant only reserved names that collide with platform namespaces. Tighten both sides: - retire-service, rollback-service, deploy-service (deploy and update-config), preview-deploy and add/remove-custom-domain now require services/<team>/<component>/values.yaml to exist in the gitops checkout before a workflow is submitted. A checkout that is stale or cannot be read refuses with 503 rather than passing. - Tenant names that equal or hyphen-prefix a platform Application name (root, shared, loki, otel, grafana, tenant, ...) are reserved, and a new tenant name may not extend, or be extended by, an existing tenant name. - A <team>-<component> pair that spells a platform-owned Application is refused for every service operation, in validation and in Submit. Existing tenants and their services stay valid; tests pin that. Claude-Session: https://claude.ai/code/session_01XgvCTQf5obEJr8NyKp73jC
|
Claude finished @mashkovd's task in 8m 26s —— View job Code review complete — changes requested (0 P1, 2 P2, 3 P3)
Findings are posted as inline comments on the diff; the review verdict is bound to P2 — should fix before merge1. The reserved-pair check breaks the domain teardown path ( 2. P3 — nice to have
What looks goodFail-closed reader semantics throughout ( Note: I could not run |
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit Summary of ReviewThe pull request adds protection against Argo CD Application namespace collisions:
Findings[P2] Bug / Premature Short-Circuit: Non-directory or symlinked service path can bypass validation or cause false negatives
[P2] Inconsistency / Logic Bug:
|
| // A service whose Argo CD Application name (<team>-<component>) is | ||
| // platform-owned is refused for every operation that addresses one. | ||
| // Executor.Submit repeats this check as well. | ||
| if team, component, ok := ServiceTarget(op, input); ok && IsReservedServiceApplication(team, component) { |
There was a problem hiding this comment.
P2 — this check also fires on the domain teardown path, which can leave a domains row permanently undeletable.
triggerRemoveCustomDomain (internal/api/handlers_domains.go:643) is the only other ValidateInput caller that passes team_name/service_name, and it runs on DELETE /api/v1/domains/{id}. For a row whose pair is now reserved — erpact/control, erpact/shared, labs/oidc-zitadel, admins/mctl-api, mctl/portal-oidc-zitadel, or any <tenant>/openclaw-skills — validation now fails, DeleteDomain maps that to 500 and keeps the row by design (see the teardown contract comment above DeleteDomain). The domain can no longer be removed through the API and its ingress host and TLS entry are never torn down. The new Executor.Submit backstop rejects the same call, so no path is left.
That is the failure mode the normalizeIdentifier comment directly above the call was added to prevent ("permanently undeletable through the API"), and the same reasoning this PR uses to exempt preview-delete: teardown of an existing record must still work. Suggest exempting the cleanup path (have triggerRemoveCustomDomain tolerate the reserved refusal), or scoping the reserved-pair check to operations that create or mutate a service rather than every operation that names one. A test over DeleteDomain with a reserved team/service row would pin it either way.
There was a problem hiding this comment.
Fixed in 691b02a. remove-custom-domain and preview-delete are now treated as teardown operations (teardownOperations / ChecksReservedPair) and skip the reserved-pair check, in both ValidateInput and the Submit backstop. They only clean up inside the tenant's own paths and namespaces and never act on the <team>-<component> Application. Pinned by TestTriggerRemoveCustomDomain_ReservedPairStillTearsDown, which drives the DELETE teardown path, and TestTeardownOperationsSkipReservedPairCheck. Both fail when the exemption is reverted.
| // on the <team>-<component> Application. | ||
| func requiresExistingService(opName string, input map[string]string) bool { | ||
| switch opName { | ||
| case "retire-service", "rollback-service", "preview-deploy", |
There was a problem hiding this comment.
P2 — preview-delete is exempt from the existence gate while its preview_id has no validation pattern, so it is the one destructive operation whose target Application name still carries a caller-chosen segment that neither new check sees.
preview-delete declares team_name, component_name and preview_id, and preview_id is the only one of the three with no Pattern in the registry (internal/operations/registry.go:520). The reserved-pair check only ever composes <team>-<component>, and this function returns false for preview-delete, so for a RoleDeveloper member of labs neither check constrains the composed name: component_name=oidc, preview_id=zitadel validates, labs-oidc is not reserved, and no existence check runs.
Whether that actually reaches labs-oidc-zitadel depends on how wft-preview-delete builds the Application name. If it is <team>-<component>-<preview_id>, it is the same hole this PR closes everywhere else; if the template interposes a fixed segment (...-preview-<id>) it is unreachable today and one template change away. Either way the cheap fix is a DNS-safe Pattern on preview_id (and ideally IsReservedServiceApplication over the composed preview name), since every other name segment this handler forwards to a workflow is pattern-checked. Worth confirming against the template before merge.
There was a problem hiding this comment.
Checked against wft-preview-delete: it touches no Argo CD Application at all. It runs helm uninstall preview-<component>-<preview_id> in <team>-preview and in the legacy <team> namespace, both namespaces the caller already has a role in. So this was not reachable, but I agree an unpatterned segment should not be forwarded. In 691b02a, preview_id gets ^[a-z0-9]{1,16}$. preview-deploy generates 6 chars from [a-z0-9]. Pinned by TestPreviewIDIsPatterned, which fails without the pattern.
| // checkFreshLocked returns an error unless the checkout has synced within | ||
| // TenantExistsMaxAge. An answer from a checkout that never synced, or that | ||
| // stopped refreshing, is not one a security decision may rest on. | ||
| func (r *Reader) checkFreshLocked() error { |
There was a problem hiding this comment.
P3 — the extracted helper is good, but it now gates three readers while the constant it enforces is still called TenantExistsMaxAge (and its doc comment says "when TenantExists answers"). ServiceExists and TenantNames are subject to the same 10-minute bound; consider renaming it to something like CheckoutMaxAge and updating the comment, so the next reader does not assume the freshness rule is tenant-specific.
There was a problem hiding this comment.
Done in 691b02a. The bound is now CheckoutMaxAge, with a doc comment that lists every reader it gates. TenantExistsMaxAge is kept as an alias because existing tests reference it, and MemberRolesMaxAge already aliased it.
| case "deploy-service": | ||
| return input["action"] != "onboard" | ||
| } | ||
| return false |
There was a problem hiding this comment.
P3 — the gate is an allowlist that defaults to "not gated", so any service operation added to the registry later is silently ungated until someone remembers this switch. Given the whole point is fail-closed, consider inverting it: gate every op for which ServiceTarget returns ok, and list the three deliberate exemptions (deploy-service with action=onboard, provision-database, preview-delete) as the exceptions. The comment above already names them, so the intent is unchanged and a future op inherits the safe default.
Related: the tests pin that onboard is not gated, but nothing pins the provision-database / preview-delete exemptions. A table test over the registry (each op that declares a service target, expected gated or not) would catch a change in either direction.
There was a problem hiding this comment.
Done in 691b02a. requiresExistingService is now fail-closed: every operation that names a service is gated except provision-database, preview-delete, and deploy-service with action=onboard. TestServiceOps_GatingTableCoversRegistry walks the registry, posts a missing service for each service operation, and asserts gated or not in both directions. It fails when an op is wrongly exempted.
| {services, true}, | ||
| {filepath.Join(services, team), true}, | ||
| {filepath.Join(services, team, component), true}, | ||
| {filepath.Join(services, team, component, "values.yaml"), false}, |
There was a problem hiding this comment.
P3 — this walk is a near-verbatim copy of the one in ComponentSourceRepo (same services root, same four steps, same i > 0 not-exist rule, same "is not a plain %s" map trick), with catalog-info.yaml swapped for values.yaml. Worth extracting a shared helper (e.g. statServiceFile(team, component, file string) (bool, error)) so the two cannot drift: a change to the containment or symlink rule should not have to be made twice.
There was a problem hiding this comment.
Done in 691b02a. ServiceExists and ComponentSourceRepo now share statServiceFileLocked(team, component, file). The existing ComponentSourceRepo tests and the new ServiceExists symlink, stale and unreadable tests still pass.
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 0 P1, 2 P2, 3 P3.
P2(1): the reserved-pair check in ValidateInput/Submit also fires on triggerRemoveCustomDomain, the DELETE /domains teardown path, where a validation error makes DeleteDomain return 500 and keep the row — a domains row for a reserved pair (erpact/control, labs/oidc-zitadel, /openclaw-skills) becomes undeletable and its ingress/TLS is never cleaned up. Same class as the exemption already granted to preview-delete.
P2(2): preview-delete is exempt from the existence gate while preview_id has no registry Pattern, leaving a caller-chosen trailing segment of the target Application name unchecked by both new checks; needs confirming against wft-preview-delete or a pattern added.
The rest reads well: fail-closed reader semantics (Lstat, no symlinks, stale/never-synced as errors), freshness bound reused consistently, the structural hyphen-prefix rule over the Application snapshot, live-Application and existing-tenant/service regression tests, and both REST and MCP paths exercised. I could not run go test, go vet or the linter in this environment, so the clean-build claim in the description is unverified here.
Review follow-ups: - remove-custom-domain and preview-delete are exempt from the reserved <team>-<component> check. Both only clean up inside the tenant's own paths and namespaces, and DELETE /domains keeps a row whose teardown is refused, so the check would make such a row undeletable. - preview_id now has a pattern, like every other name segment forwarded to a workflow. - The existence gate is fail-closed by default: every operation that names a service is gated unless it is listed as exempt. A table test over the registry pins the classification. - ServiceExists and ComponentSourceRepo share one stat walk, and the freshness bound is named CheckoutMaxAge. Claude-Session: https://claude.ai/code/session_01XgvCTQf5obEJr8NyKp73jC
|
Replies to the agy review on
|
|
Claude finished @mashkovd's task in 7m 33s —— View job Code review complete — changes requested (0 P1, 2 P2, 2 P3)
Findings are inline on the diff; the verdict is bound to Both earlier P2s are addressed
P2 — should fix before mergeBoth concern 1. The composed preview name is still unchecked across the hyphen boundary ( 2. The new P3 — nice to have
What looks goodFail-closed reader semantics throughout ( Note: |
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
| // always be cleaned up (DELETE /domains keeps a row whose teardown fails). | ||
| var teardownOperations = map[string]struct{}{ | ||
| "remove-custom-domain": {}, | ||
| "preview-delete": {}, |
There was a problem hiding this comment.
P2 — the teardown exemption is right for remove-custom-domain, but for preview-delete it leaves the caller in control of the full target name across a hyphen boundary, which is the one hole the earlier review asked about and the new preview_id pattern does not close.
preview-delete is now exempt from both new checks: ChecksReservedPair skips the reserved-pair refusal and requiresExistingService returns false, so nothing constrains the composed name. The new preview_id pattern forbids a hyphen inside the segment but not the one the name is built with, so a RoleDeveloper member of labs can still send component_name=oidc, preview_id=zitadel and have the workflow compose oidc-zitadel / labs-oidc-zitadel — the platform-managed Zitadel Application for that tenant. shared/pg/<id> is reachable the same way for anything built as <team>-<component>.
The comment above says the operation "uninstalls a Helm release in <team>-preview or <team>". If the release name is derived from component_name plus preview_id and the namespace can be <team>, then helm uninstall oidc-zitadel -n labs is a developer-reachable teardown of a platform release — the exact class this PR closes elsewhere. wft-preview-delete is not in this repo, so the exemption currently rests on a claim I cannot verify here.
The cheap fix needs no template change: keep the teardown exemption for the pair, and additionally check the composed preview name — IsReservedServiceApplication(team, component + "-" + preview_id) — in ValidateInput and Submit for preview-delete. A legitimate preview is never named after a platform Application, so this refuses nothing real, and unlike a domains row no record becomes undeletable (previews also expire by TTL).
There was a problem hiding this comment.
Fixed in d227fa2. preview-delete still skips the per-service reserved-pair check (so teardown keeps working), but ReservedServiceTarget now checks the name it composes, <component_name>-<preview_id>, against the reserved set. ValidateInput and the Submit backstop both use it. TestPreviewDeleteComposedNameIsChecked covers it, and it fails when the composed check is removed (mutation verified).
| {Name: "team_name", Type: "string", Required: true, Description: "Team name", Pattern: "^[a-z0-9][a-z0-9-]{0,30}$"}, | ||
| {Name: "component_name", Type: "string", Required: true, Description: "Service name", Pattern: "^[a-z0-9][a-z0-9-]{0,30}$"}, | ||
| {Name: "preview_id", Type: "string", Required: true, Description: "Preview ID returned by preview-deploy"}, | ||
| {Name: "preview_id", Type: "string", Required: true, Description: "Preview ID returned by preview-deploy", Pattern: "^[a-z0-9]{1,16}$"}, |
There was a problem hiding this comment.
P2 — this narrows an identifier the platform itself generates, and nothing in this repo pins the generator's format, so a valid preview_id handed out by preview-deploy may now be rejected.
preview_id is produced by wft-preview-deploy and echoed back to the caller ("Preview ID returned by preview-deploy"; the MCP tool just forwards what the user pastes). This repo generates no preview ids — operations/preview.go only builds image_tag — so there is no test or constant here that the new pattern agrees with the producer. The pattern admits no hyphen and at most 16 characters, so any id shaped like pr-42, feat-login, or a longer timestamp/uuid combination (compare GeneratePreviewTag, which does emit hyphens) now fails validation with a 400 and the preview can only be removed by waiting out the TTL.
Worth confirming against wft-preview-deploy's id generation before merge, and if ids can contain hyphens, widen to a DNS-safe pattern that still bounds length (for example ^[a-z0-9][a-z0-9-]{0,30}$) and close the composed-name hole with the IsReservedServiceApplication check suggested on reserved_application.go instead — the pattern alone does not close it either way, since component_name=oidc + preview_id=zitadel already passes it.
There was a problem hiding this comment.
Fixed in d227fa2: preview_id is now ^[a-z0-9][a-z0-9-]{0,30}$ (a DNS-safe segment that may contain hyphens). The platform generator only emits [a-z0-9]{6} (wft-preview-deploy: tr -cd 'a-z0-9' | tail -c 6), so the wider pattern accepts every id it produces plus hyphenated ids from other callers. TestPreviewIDIsPatterned accepts pr-42 and rejects -x.
| // answers. The checkout refreshes every minute; an older last sync could miss | ||
| // a tenant created since, so it is an error rather than "absent". | ||
| const TenantExistsMaxAge = 10 * time.Minute | ||
| const TenantExistsMaxAge = CheckoutMaxAge |
There was a problem hiding this comment.
P3 — the rename landed, but keeping the old name as an alias leaves three exported constants for one value: CheckoutMaxAge, TenantExistsMaxAge (now only referenced by reader.go:663's doc comment and two tests) and MemberRolesMaxAge = TenantExistsMaxAge at line 794. Since nothing outside this package uses them, consider dropping both aliases and pointing the tests and MemberRoles at CheckoutMaxAge directly.
Related: MemberRoles still open-codes the never-synced/stale pair of checks (lines 817-823) instead of calling the new checkFreshLocked. That is the one reader the new helper's doc comment claims to cover, so having it keep its own copy is exactly the drift the extraction was meant to prevent.
There was a problem hiding this comment.
Done in d227fa2: removed the TenantExistsMaxAge/MemberRolesMaxAge aliases, and MemberRoles now calls checkFreshLocked. Removing that call makes TestMemberRoles_UnreadableIsNeverAnAnswer fail (mutation verified).
| } | ||
| f := newRoleFixture(t) | ||
| w := postAs(t, f.router, "/api/v1/operations/"+op.Name+"/execute", body, member("olga-owner")) | ||
| gated := w.Code == http.StatusNotFound |
There was a problem hiding this comment.
P3 — the gating table is a good addition (it pins all three exemptions now, which the previous revision did not), but gated := w.Code == http.StatusNotFound makes the negative case pass for the wrong reason: any 400/403 also reads as "not gated". If provision-database or preview-delete later grows a required parameter that fill does not supply, this test keeps passing while asserting nothing. Asserting the expected status directly — 404 when gated, 202 when not — would pin both directions.
There was a problem hiding this comment.
Done in d227fa2: TestServiceOps_GatingTableCoversRegistry now asserts exactly 404 for gated operations and exactly 202 for ungated ones.
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 0 P1, 2 P2, 2 P3.
Both P2s from the previous revision were addressed well: the domain teardown path is exempt via teardownOperations and pinned by a test over triggerRemoveCustomDomain, the existence gate is now fail-closed by default with all three exemptions pinned by a registry-walking table test, the path walk is shared with ComponentSourceRepo, and CheckoutMaxAge is renamed.
Remaining P2s, both on preview-delete:
- It is now exempt from the reserved-pair check as well as the existence gate, so nothing constrains the composed target name across the hyphen boundary:
component_name=oidc+preview_id=zitadelstill spellslabs-oidc-zitadel. The exemption's safety rests onwft-preview-delete's naming, which is not verifiable in this repo, and the code comment allows namespace<team>. Fix without touching the template: also checkIsReservedServiceApplication(team, component+"-"+preview_id)for this operation. - The new
preview_idpattern (no hyphens, 16 chars) narrows an identifierwft-preview-deploygenerates and this API hands back, with nothing in-repo pinning the producer's format; an id likepr-42would now 400 and leave the preview removable only by TTL.
Everything else holds up: fail-closed reader semantics, the structural hyphen-prefix rule over the Application snapshot, the live-Application and existing-tenant/service regression pins, and both REST and MCP paths exercised. ServiceTarget returning ok with empty values is not reachable from ExecuteOperation (all eight service operations declare both parameters as required and patterned, and ValidateInput runs first), and the only other Submit call sites are the platform-skill ones (no team_name) and the exempt domains teardown.
Note: go test, go vet and golangci-lint could not be run in this environment, so the clean-build claim in the description is unverified here.
Review follow-ups: - preview-delete still skips the reserved-pair check for its service, but is now checked on the name it composes from component_name and preview_id. Neither parameter's pattern sees the hyphen between them. - preview_id accepts a DNS-safe segment with hyphens again (up to 31 chars), so ids from other producers are not rejected. - The freshness aliases are gone; MemberRoles uses checkFreshLocked too. - The gating table test asserts the exact status in both directions. Claude-Session: https://claude.ai/code/session_01XgvCTQf5obEJr8NyKp73jC
|
Claude finished @mashkovd's task in 5m 19s —— View job Code review complete — approved (0 P1, 0 P2, 3 P3)
Findings are inline on the diff; the verdict is bound to Both previous P2s are resolved
Verified in this pass
P3 — nice to have
Note: |
Antigravity (agy) review — informationalModel: gemini-3.8-flash-medium · commit No significant issues found. |
| return team, component, IsReservedServiceApplication(team, component) | ||
| } | ||
| if id := params["preview_id"]; id != "" && declares(op, "preview_id") { | ||
| composed := component + "-" + id |
There was a problem hiding this comment.
P3 — the composed-name refusal is the right call, but the message it produces describes a service the caller never named.
ReservedServiceTarget returns the composed name, and ValidateInput feeds it to reservedServiceApplicationMessage (internal/operations/registry.go:275), so a developer who sends component_name=oidc, preview_id=zitadel is told "labs"/"oidc-zitadel" is reserved for platform use; choose a different service name — neither parameter has that value, and there is no service name to choose differently for a delete. Same for the Submit backstop, which wraps labs-oidc-zitadel. A preview-specific variant (naming preview_id as the part that makes the pair reserved) would point at the field the caller actually has to change. The check itself and the declares(op, "preview_id") guard both look right, and the pair-exemption case (oidc-zitadel + abc123) is pinned.
There was a problem hiding this comment.
Agreed. Deferred to a follow-up so the approved head stays unchanged: a preview-specific message that names preview_id as the field that makes the composed name reserved.
| } | ||
|
|
||
| // Every registry operation that names a service is classified here, so an | ||
| // operation added later fails this test until someone decides whether it is |
There was a problem hiding this comment.
P3 — asserting the exact status in both directions is a real improvement over the previous gated := w.Code == 404, but the comment above still claims more than the test delivers: a service operation added later inherits requiresExistingService == true, so it is expected to 404, and with a missing service it will 404 — the test passes without anyone deciding anything. It only fails if fill happens to lack one of the new operation's required parameters (then validation 400s instead).
Comparing reg.List()'s service operations against an expected set of names, rather than only seen < 8, would make an added operation fail here by construction and keep the comment accurate.
There was a problem hiding this comment.
Agreed. Deferred to a follow-up: compare the registry's service operations against an explicit expected set, so an operation added later fails this test by construction.
| // except teardown (see teardownOperations). | ||
| // Executor.Submit repeats this check as well. | ||
| if team, name, reserved := ReservedServiceTarget(op, input); reserved { | ||
| errors = append(errors, reservedServiceApplicationMessage(team, name)) |
There was a problem hiding this comment.
P3 — putting the refusal in ValidateInput gives the clean 400 and covers every caller, but it also means this denial leaves no audit record: ExecuteOperation returns at internal/api/handlers_write.go:230 before auditParams is built, while every other denial in that handler (role, tenant access, tenant existence, the new tenant-name collision, the new existence gate) logs Status: "denied".
A request that tries to address erpact/control, labs/openclaw-skills or labs-oidc-zitadel is the kind of event most worth having a record of, and Executor.Submit's backstop only logs at warn level — there is no audit entry on either path. The same applies to the pre-existing IsReservedTenantName refusal just above, so this may be better as a small follow-up: either audit validation failures generally, or log the two reserved-name refusals specifically before writing the 400.
There was a problem hiding this comment.
Agreed, and it applies equally to the existing IsReservedTenantName refusal. Deferred to a follow-up: write a denied audit entry for reserved-name validation failures before ExecuteOperation returns the 400.
There was a problem hiding this comment.
No P1/P2 findings (3 P3). Good to merge.
Both P2s from the previous revision are resolved. preview-delete keeps the teardown exemption for its own pair but is now refused on the name it composes from component_name + preview_id, in both ValidateInput and the Submit backstop, with the hyphen boundary and the exempt-pair case pinned by TestPreviewDeleteComposedNameIsChecked. preview_id is back to a DNS-safe bounded pattern, so a generated id with hyphens is no longer rejected. The freshness aliases are gone and MemberRoles now shares checkFreshLocked, and the gating table asserts 404 vs 202 explicitly.
Checked and holding: ComponentSourceRepo's semantics are byte-for-byte preserved by the extracted statServiceFileLocked (missing path below services -> ErrSourceRepoNotRegistered, symlink/non-plain -> error, missing services root -> error); no remaining references to the removed TenantExistsMaxAge / MemberRolesMaxAge; the only Submit call sites are ExecuteOperation, the exempt domains teardown, and the platform-skill handlers (no team_name); exactly the eight registry operations that declare team_name plus a component parameter reach the gate, with the three exemptions documented and pinned; and the structural hyphen-prefix rule leaves all six existing tenants and all their current services valid.
P3s posted inline: the composed-name refusal message names a service the caller never sent; the gating table's comment claims more than it enforces; the reserved-pair/reserved-tenant 400 is the one denial in ExecuteOperation with no audit entry.
Note: go test, go vet and golangci-lint could not be run in this environment (commands not permitted), so the clean-build claim in the description is unverified here.
Summary
Hardening for tenant-scoped service operations and tenant naming. Workflows address a tenant's service through the Argo CD Application name
<team>-<component>, which shares one name space with the platform's own Applications. This PR makes mctl-api check that pair before submitting anything.1. Service must exist (fail closed)
retire-service,rollback-service,deploy-service(deploy/update-config),preview-deploy,add-custom-domainandremove-custom-domainnow requireplatform-gitops/services/<team>/<component>/values.yamlto exist in the gitops checkout. That directory is exactly what theappsApplicationSet turns into<team>-<component>.gitops.Reader.ServiceExists: only a missing path is "absent". A stale or never-synced checkout, a symlink or non-plain entry on the path, or any other stat error is an error, and the API answers 503 without submitting.deploy-service action=onboard(it creates the service),provision-database(may run before onboarding, does not act on the Application) andpreview-delete(must still clean up after a retire). All three still get check 3.2. Reserved tenant names
Nis reserved ifN == PorPstarts withN-for anyPinplatformApplicationNames(derived fromkubectl get applications -n argocdon 2026-10-11 plusbootstrap/templates). It newly reserves, among others:root,root-app,shared,shared-pg,cloudnative,cloudnative-pg,external,grafana,grafana-iac,loki,loki-stack,otel,otel-collector,otel-eval,otel-eval-tempo,reflector,valkey,temporal-web,vault-human-auth-iac.tenant, new prefixestenant-andotel-.labs-xnext tolabs) or be extended by one. It reads the tenant list from gitops with a new strictReader.TenantNames; a read failure means 503. Admins are included, and re-running create-tenant for the same name is not a collision.admins-mctl-{agent,api,portal},erpact-{control,deployer,infra,oidc-zitadel,shared},labs-oidc-zitadel,mctl-portal-oidc-zitadel) cannot feed the tenant rule without reserving the existing tenants. They are covered by check 3.The existing tenants
admins,erpact,karabu,labs,mctlandovkstay valid, and so do all their current services. Tests pin both.3. Reserved
<team>-<component>pairsEvery operation that takes
team_namepluscomponent_name/service_name/app_namerefuses a pair that spells a platform Application (either list above), anytenant-*name, or the componentopenclaw-skills. The refusal happens inRegistry.ValidateInput(400) and again inExecutor.Submitas a backstop.MCP tools reach these operations through the same REST handler, so both paths enforce the checks. Tests drive real
tools/callrequests to confirm it.Follow-up needed in mctl-gitops (not in this PR)
tenant_name_reservedinplatform-gitops/argo-workflows/cluster-templates/wft-create-tenant.yamlshould mirror the new reservations:tenant,root,shared,cloudnative,external,grafana,loki,otel,reflector,valkey, plus each full platform Application name not already covered (root-app,shared-pg,cloudnative-pg,loki-stack,grafana-iac,temporal-web);tenant-*,otel-*;The cross-tenant prefix rule needs the tenant list. The workflow already clones gitops, so it could apply the same rule over
platform-gitops/tenants/*.Do not add
admins,erpact,labsormctlto the shell list. The shell check runs even withreprovision=true, so listing them would break re-provisioning of those tenants.Maintenance
platformApplicationNamesandtenantScopedPlatformApplicationsininternal/operations/reserved_tenant.godocument how to re-derive them.TestLiveApplicationsAreProtectedchecks a snapshot of the live Application list against both, so when a platform Application is added, add it to the snapshot and to one of the two lists.Test plan
go test ./...,go vet ./...,golangci-lint run(v2.11.4, Go 1.26.9): clean.govulncheck: no called vulnerabilities.tenant-prefix), and at least one test failed every time.🤖 Generated with Claude Code
https://claude.ai/code/session_01XgvCTQf5obEJr8NyKp73jC