Skip to content

fix(api): refuse create-tenant for a tenant that already exists - #452

Merged
mashkovd merged 2 commits into
mainfrom
fix/create-tenant-existing
Oct 4, 2026
Merged

mashkovd merged 2 commits into
mainfrom
fix/create-tenant-existing

Conversation

@mashkovd

@mashkovd mashkovd commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

What

POST /api/v1/operations/create-tenant/execute (also used by the MCP mctl_create_tenant tool) now checks, for non-admin callers, whether platform-gitops/tenants/<tenant_name> already exists in the API's gitops checkout before submitting anything:

  • exists → 409 workspace "<name>" already exists;
  • cannot tell → 503. That covers a checkout that never synced, a last successful sync older than 10 minutes (gitops.TenantExistsMaxAge), or a stat error other than "not found";
  • absent → submitted as before.

Every refusal is audited as denied. Admins are not checked.

Why

create-tenant makes a new tenant. When the tenant directory already exists, wft-create-tenant skips writing the tenant files (SKIP_GIT, added in 935f0bbf as the retry path for a half-finished run) and carries on with Vault and the ApplicationSet. A request for a name that is already taken should get a clear answer, not a workflow that "succeeds" without creating anything.

Admins are not checked here; for them, the workflow decides. Today it re-runs provisioning. With mctlhq/mctl-gitops#1650 it fails in validate unless submitted with reprovision=true, which the API does not declare, so a re-run is a deliberate Argo submission. Non-admins could never use that path for their own tenant anyway. Once the first run commits the tenant, its creator is a member, and the one-workspace rule refuses them.

Changes

  • internal/gitops/reader.go: adds TenantExists(name) (bool, error). It rejects names that are not a single path segment. Only ErrNotExist counts as absent; any other error is reported as an error.
  • internal/api/interfaces.go: GitReader gains TenantExists.
  • internal/api/handlers_write.go: the check runs after input validation and before submit.
  • internal/openapi/openapi.yaml: documents the 409 and 503 responses on execute.

Tests

  • internal/api/create_tenant_existing_test.go:
    • an existing tenant gets 409 and is not submitted;
    • a new tenant gets 202 and is submitted as the caller;
    • an uncheckable checkout gets 503 and is not submitted;
    • an admin re-run gets 202.
  • internal/gitops/reader_test.go:
    • never synced and stale checkouts are errors;
    • a directory, a directory without values.yaml, and a plain file all count as existing;
    • a missing name counts as absent;
    • invalid names are errors;
    • an unreadable tenants directory is an error, not "absent".
  • On the old handler (check not called), the 409 and 503 tests fail. 7 of 7 mutations are caught:
    • check not called;
    • admins checked;
    • error treated as absent;
    • never-synced accepted;
    • stale accepted;
    • stat error treated as absent;
    • name check dropped.
  • go test ./... passes. golangci-lint run reports only the gosec G710 at internal/api/router.go:274 that is already on main.

Type of change

  • Bug fix

Checklist

  • Tests pass (go test ./...)
  • Linter passes (only the existing G710 on main)
  • Documentation updated (OpenAPI)
  • Commit messages follow conventional commits

🤖 Generated with Claude Code

https://claude.ai/code/session_01D1kGUJMWeA8hyhQuxKuHd2

create-tenant makes a new tenant; it never joins one. When the tenant
directory already exists, the workflow skips writing the tenant and
carries on provisioning, which is the retry path for a half-finished
run. A request for a taken name should say so instead of starting that
path. The API now answers 409 before submitting anything when the name
is taken, and 503 when the gitops checkout cannot tell (never synced,
older than 10 minutes, or unreadable): "could not check" is not
"absent".

Admins are not checked, so they can still re-run create-tenant to
finish or redo a tenant's provisioning.

Claude-Session: https://claude.ai/code/session_01D1kGUJMWeA8hyhQuxKuHd2
Copilot AI balanced review requested due to automatic review settings October 4, 2026 17:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 55s —— View job


Review complete for PR #452 (commit bb33ca3)

  • Gather context (PR body, changed files)
  • Diff a55bc4c...bb33ca3
  • Review internal/gitops/reader.go (TenantExists)
  • Review internal/api/handlers_write.go (check wiring)
  • Review tests
  • Post inline findings
  • Submit verdict

Verdict: APPROVE — no P1/P2 findings, 2 P3 notes posted inline.

What I checked:

  • TenantExists (internal/gitops/reader.go): rejects non-single-segment names (blocks a/b, ../tenants, ., ..), uses the same r.mu.RLock() pattern as GetTenant/ListTenants, correctly treats only fs.ErrNotExist as "absent," and enforces the 10-minute staleness bound (TenantExistsMaxAge) before answering.
  • Handler wiring (internal/api/handlers_write.go): the check runs after ValidateInput (so tenant_name is already pattern-validated: ^[a-z0-9][a-z0-9-]{1,62}$) and before Submit; tenantParam comes from the same extractTenantParam used for RBAC; admins correctly bypass; denial is audited with the same auditParams used elsewhere.
  • Tests: new cases in create_tenant_existing_test.go and reader_test.go cover 409/503/202 paths, invalid names, unreadable dir, and admin re-run, matching the PR's own description.
  • Could not run go build/go test/golangci-lint locally — sandboxed go commands required approval that wasn't available in this run. Verified correctness by static review (locking pattern cross-checked against sibling methods, imports confirmed present) instead.

P3 (non-blocking) notes posted inline:

  1. internal/gitops/reader.go:630 — the wrapped stat error can leak the server's absolute checkout path into the client-facing 503 body (via err.Error() in checkTenantIsNew). Matches an existing convention elsewhere in the same handler, so not a regression.
  2. internal/api/handlers_write.go:217 — a check-then-submit race: two concurrent non-admin requests for the same brand-new tenant name can both pass TenantExists (false) and both get submitted, since the check only consults the periodically-synced checkout, not in-flight submissions. Pre-existing race class, outside this PR's stated scope (already-existing tenants).

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit bb33ca3.

No significant issues found.

Comment thread internal/gitops/reader.go Fixed
Comment thread internal/gitops/reader.go
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("checking tenant %q: %w", name, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the wrapped stat error (e.g. an os.PathError containing the absolute checkout path) propagates verbatim into the HTTP 503 body via checkTenantIsNew in handlers_write.go, which does err.Error() into the client-facing message. This matches the existing convention elsewhere in this handler (e.g. the Submit-failure path), so not a regression, but worth a note: it leaks server filesystem layout to API callers on this new path. Not blocking.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3a18a8. When the check fails, the 503 body is now a fixed message ("cannot check whether workspace "" exists right now; try again later"), and the underlying error goes to slog.Error instead. TestCreateTenant_UncheckableRefuses feeds in an error containing an absolute path and asserts that the body contains neither the path nor lstat. Putting err.Error() back in the body fails that test.

// provisioning. A checkout that cannot answer refuses too: "could not
// check" is not "does not exist".
if opName == "create-tenant" && !user.IsAdmin() {
if status, msg := h.checkTenantIsNew(tenantParam); status != 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: check-then-submit race — checkTenantIsNew only consults the periodically-synced gitops checkout (refreshed every minute, tolerated stale up to TenantExistsMaxAge=10m), not in-flight submissions. Two concurrent non-admin requests for the same brand-new tenant name can both pass this check and both get submitted before either lands in the checkout. This is a pre-existing class of race (not introduced here) and the PR's own description scopes the fix to the already-exists case, so not blocking, but worth flagging since de-duplicating concurrent creates was part of the motivation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that this is check-then-submit, and it is deliberately not the only guard. Since mctlhq/mctl-gitops#1650 (merged), wft-create-tenant refuses an existing tenant in two places, without reprovision=true:

  • in validate, on a fresh clone;
  • in the commit step, for a tenant that appeared between validate and its own clone.

So a duplicate that slips past this API check within the sync window still fails loudly in the workflow, instead of provisioning. This check is the early, cheap answer. Leaving it as is.

claude[bot]
claude Bot previously approved these changes Oct 4, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (2 P3). Reviewed internal/gitops/reader.go (TenantExists: name validated as a single path segment, lock usage matches GetTenant/ListTenants, correctly distinguishes ErrNotExist from other stat errors, staleness bound respected), internal/api/handlers_write.go (check runs after validation/before submit, tenantParam sourced from the already-validated tenant_name, admin bypass correct, audit logged on denial), and the new tests (good coverage per the PR's own mutation-testing claims). P3 notes left inline: (1) wrapped stat errors may leak server filesystem paths to API clients via the 503 body — consistent with existing convention elsewhere in this handler, not a regression; (2) a check-then-submit race lets two concurrent non-admin requests for the same brand-new tenant name both pass and both submit — pre-existing race class, out of scope for this fix. Good to merge.

TenantExists now accepts only create-tenant's tenant_name pattern and
checks that the cleaned path stays under platform-gitops/tenants/
before the lstat, so the path read is provably one entry of that
directory. The 503 for a failed check no longer echoes the checkout
error, which can carry absolute paths; it is logged instead.

Claude-Session: https://claude.ai/code/session_01D1kGUJMWeA8hyhQuxKuHd2
@claude

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 1m 55s —— View job


Review complete for PR #452 (commit f3a18a8)

  • Gather context (PR body, prior review comments, CodeQL finding)
  • Diff a55bc4c...f3a18a8 (and isolate bb33ca3..f3a18a8 to see what's net-new in this commit)
  • Review TenantExists name validation in internal/gitops/reader.go
  • Review handlers_write.go error-message handling
  • Review tests
  • Submit verdict

Verdict: APPROVE (0 P1/P2/P3 — nothing new to flag)

What this commit (f3a18a8) changes, on top of bb33ca3:

It directly addresses the two open items from the earlier review of bb33ca3:

  1. CodeQL "uncontrolled data used in path expression" (internal/gitops/reader.go) — TenantExists now validates name against tenantNameRE (^[a-z0-9][a-z0-9-]{1,62}$), the same pattern already used for tenant_name elsewhere in internal/operations/registry.go. It then additionally checks filepath.IsLocal(name) and that the cleaned joined path has the tenants-root as a strict prefix before calling os.Lstat. Given the regex already excludes /, ., and uppercase/underscore, the containment check is redundant in practice, but it gives the static analyzer a recognizable sanitization shape — reasonable as a direct response to the CodeQL alert, not a correctness issue.
  2. P3 from the earlier review (checkout path/error leaking into the 503 body) — checkTenantIsNew in internal/api/handlers_write.go no longer interpolates err.Error() into the client-facing message; it now does slog.Error("create-tenant existence check failed", "tenant", tenant, "error", err) and returns a fixed "...try again later" message. slog was already imported in this file.

Tests: reader_test.go's bad-name table was extended (Bad_Name, -x, x, /etc) and create_tenant_existing_test.go's uncheckable-case test now asserts the 503 body does not contain the checkout path or lstat, directly covering both fixes.

Verification: Reviewed statically — confirmed the regex matches the existing tenant_name pattern in registry.go, confirmed slog is already imported, confirmed no other caller of TenantExists needs updating. Could not run go build/go test/golangci-lint locally (sandboxed go commands required approval not available in this run, same limitation noted in the previous review pass).

No P1/P2 findings, no new P3s. Good to merge.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit f3a18a8.

No significant issues found.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (0 P3). This commit cleanly addresses both the CodeQL path-taint alert and the prior P3 (error-message leak) from the review of bb33ca3: TenantExists now validates the name against the same tenant_name pattern used elsewhere (single DNS-safe path segment), adds a filepath.IsLocal + root-prefix containment check before Lstat, and the handler now logs the raw stat error via slog.Error instead of embedding it in the 503 body. Verified by static review (go build/test blocked by sandbox approval in this run).

@mashkovd
mashkovd merged commit 7680577 into main Oct 4, 2026
10 checks passed
@mashkovd
mashkovd deleted the fix/create-tenant-existing branch October 4, 2026 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants