Skip to content

fix(server,ui,openapi): answer null tenant and role for a user in no namespace - #7171

Merged
gustavosbarreto merged 2 commits into
masterfrom
fix/nullable-auth-tenant-role
Sep 23, 2026
Merged

gustavosbarreto merged 2 commits into
masterfrom
fix/nullable-auth-tenant-role

Conversation

@geovannewashington

@geovannewashington geovannewashington commented Sep 22, 2026

Copy link
Copy Markdown
Member

What

Auth endpoints now answer null for tenant and role when a user belongs to no namespace, instead of an empty string.

Closes #7166

Needs shellhub-io/cloud#2577, which merges first. Without it the cloud / validate-server-enterprise check is red here and cloud master breaks on merge.

Why

role: "" is not one of the four values the OpenAPI role enum allows, so the server was breaking its own contract. The console could not tell "this user has no namespace" from "the field was not sent", because "" is neither null nor undefined.

Having no namespace is a normal state, not an edge case. A user is in it right after confirming their account, and again after leaving their last namespace.

Changes

  • UserAuthResponse.Tenant is now *string and Role is *authorizer.Role. No omitempty, so both keys are always present, and absent means null.
  • AuthLocalUser and CreateUserToken leave both nil when the user holds no membership. Tenant resolution, refusals and preferred-namespace handling are unchanged.
  • The OpenAPI schema marks both nullable: true and keeps them required. The shared namespaceMemberRole schema is untouched, since seven other files reference it.
  • The console store clears role on an explicit null and keeps the current one when the field is absent. The two cases are different and ?? cannot tell them apart.
  • Corrected the Role.String() doc comment. It returns "", never the "N/A" the old comment claimed.

Testing

Automated:

  • Service tests for both methods with no membership.
  • A route test that reads the raw HTTP body and asserts "tenant": null, "role": null, so a dropped key cannot pass as an absent one.
  • A console store test for the explicit null.
  • Full server suite, 2407 UI tests, console build and lint, redocly lint, golangci-lint and go mod tidy all clean.

Manual, to exercise it on a dev stack:

  1. Start the validator. ./bin/docker-compose up -d openapi && ./bin/docker-compose restart server. Look for Enabling OpenAPI response validation in report mode, and no Failed to initialize OpenAPI validator after it.
  2. Make a user with no namespace. ./bin/cli user create nobody 'Secret123!-' nobody@test.com
  3. Log in as them.
curl -s http://localhost/api/login -H 'Content-Type: application/json' \
  -d '{"username":"nobody","password":"Secret123!-"}' | jq '{tenant, role}'

You get {"tenant": null, "role": null} and no validator warning.

  1. To see the old behaviour, check out the parent commit, let air rebuild, and repeat step 3. It gives {"tenant": "", "role": ""} plus a OpenAPI response validation failed warning naming /role.
  2. Clean up. ./bin/cli user delete nobody

Two callers outside the API needed updating. The e2e assertion in tests/identity_access_test.go compared against a string, which a *string can never equal. Ten MFA test cases in cloud expected "" and are fixed in shellhub-io/cloud#2577.

…namespace

The console store checks role === undefined rather than role ?? get().role,
because the nullish form would keep a stale role after switching into a
namespace that grants none.

Role.String returns the empty string for every role outside the four assignable
ones, RoleInvalid included. The old doc comment claimed "N/A", which the code
never returned.

Fixes: #7166
@github-actions

github-actions Bot commented Sep 22, 2026

Copy link
Copy Markdown

Code Review Complete

The automated review ran but did not post an updated summary — this usually means no new issues were found since the previous review. If you've pushed changes and want a fresh pass, comment /review.

View job

@gustavosbarreto

Copy link
Copy Markdown
Member

Can we add an e2e case for this in this PR instead of leaving it for later?

The e2e stack already runs with SHELLHUB_OPENAPI_VALIDATION=strict (docker-compose.test.yml), so a response that breaks the contract comes back as a 500. That makes the contract itself the assertion. No e2e test logs in a user without a namespace today: they all go through newSSHEnvironment, which creates one first. Right now the only thing standing between this fix and a regression is the manual step in the description.

Something like:

func TestLoginWithoutANamespace(t *testing.T) {
	compose := environment.New(t).Up(t.Context())
	t.Cleanup(compose.Down)

	compose.NewUser(t, "nobody", "nobody@ossystems.com.br", ShellHubPassword)

	res, err := compose.R(t.Context()).
		SetBody(map[string]string{"username": "nobody", "password": ShellHubPassword}).
		Post("/api/login")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, res.StatusCode())

	var body map[string]any
	require.NoError(t, json.Unmarshal(res.Body(), &body))

	for _, key := range []string{"tenant", "role"} {
		require.Contains(t, body, key)
		assert.Nil(t, body[key])
	}
}
  • The 200 is the main assertion: strict mode answers 500 if the schema rejects the null.
  • Decoding into map[string]any rather than UserAuthResponse keeps a dropped key from passing as null, same reason as the route test.
  • On master it should fail with a 500, since role: "" breaks the enum, so it reproduces the bug before it covers the fix.

A second case for a user who leaves their only namespace would cover CreateUserToken as well. Both can share one stack as subtests.

The e2e stack runs the API under SHELLHUB_OPENAPI_VALIDATION=strict, so the
200 carries the assertion: a body the schema rejects comes back as a 500. On
the commit before the fix this case fails with exactly that, so it reproduces
the bug before it covers it.

Decoding into a map rather than UserAuthResponse keeps a dropped key from
reading as a null one, since both reach the struct as a nil pointer.

Refs: #7166
@geovannewashington

Copy link
Copy Markdown
Member Author

Added in 8e6cfb0 as tests/auth_test.go, close to your sketch.

Two changes. The login sits inside require.EventuallyWithT with the same 30s/1s window as newSSHEnvironment at ssh_test.go:1746, since the stack needs a moment after Up before it answers and a bare post would be flaky. The decode moved inside that callback for the same reason.

Verified both directions locally, building the images the same way CI does:

  • On the branch: --- PASS: TestLoginWithoutANamespace (150.14s)
  • On 253330822, the commit before the fix: --- FAIL, with expected: 200, actual: 500

So it reproduces the bug before it covers it, exactly as you said it would.

@gustavosbarreto
gustavosbarreto merged commit 00e1ce0 into master Sep 23, 2026
42 checks passed
@gustavosbarreto
gustavosbarreto deleted the fix/nullable-auth-tenant-role branch September 23, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auth endpoints return an empty tenant and role for a user with no namespace

2 participants