Skip to content

Allow the creation of API-only users#43440

Open
juan-fdz-hawa wants to merge 17 commits intomainfrom
42882_42880_42884_allow_creation_of_api_only_users
Open

Allow the creation of API-only users#43440
juan-fdz-hawa wants to merge 17 commits intomainfrom
42882_42880_42884_allow_creation_of_api_only_users

Conversation

@juan-fdz-hawa
Copy link
Copy Markdown
Contributor

@juan-fdz-hawa juan-fdz-hawa commented Apr 11, 2026

Related issues:

Changes

  • Added POST /users/api_only endpoint for creating API-only users.
  • Added PATCH /users/api_only/{id} for updating existing API-only users.
  • Updated fleetctl user create --api-only removing email/password field requirements.

Checklist for submitter

If some of the following don't apply, delete the relevant line.

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Summary by CodeRabbit

  • New Features

    • Added API-only user management endpoints (create & modify) and include api_endpoints on user GET/list responses.
  • CLI

    • fleetctl user create --api-only no longer requires email/password and prints the API token on creation.
  • Behavior

    • API endpoint permissions for API-only users enforce premium/license rules and new validations (max count, wildcard rules, catalog checks); invite/admin flows reject api_endpoints where disallowed.
  • Tests

    • Added integration and unit tests for API-only creation, modification, validation, and GET responses.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 11, 2026

Codecov Report

❌ Patch coverage is 72.85223% with 79 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.91%. Comparing base (577fe75) to head (10caf68).
⚠️ Report is 27 commits behind head on main.

Files with missing lines Patch % Lines
server/service/users.go 74.26% 29 Missing and 15 partials ⚠️
server/datastore/mysql/users.go 72.72% 7 Missing and 8 partials ⚠️
cmd/fleetctl/fleetctl/user.go 47.82% 7 Missing and 5 partials ⚠️
server/utils.go 68.42% 3 Missing and 3 partials ⚠️
server/service/client_users.go 80.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #43440      +/-   ##
==========================================
+ Coverage   66.90%   66.91%   +0.01%     
==========================================
  Files        2594     2598       +4     
  Lines      207772   208512     +740     
  Branches     9208     9208              
==========================================
+ Hits       139006   139533     +527     
- Misses      56131    56277     +146     
- Partials    12635    12702      +67     
Flag Coverage Δ
backend 68.70% <72.85%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- Added POST /users/api_only endpoint for creating API-only users.
- Added PATCH /users/api_only/{id} for updating existing API-only users.
- Updated `fleetctl user create --api-only` removing email/password field requirements.
@juan-fdz-hawa juan-fdz-hawa force-pushed the 42882_42880_42884_allow_creation_of_api_only_users branch from 5a4a9ea to a7ff10b Compare April 11, 2026 18:43
@juan-fdz-hawa
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 11, 2026

✅ Actions performed

Full review triggered.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds API-only user management: new endpoints POST /api/version/fleet/users/api_only and PATCH /api/version/fleet/users/api_only/{id}; expands User and UserPayload with api_endpoints and persists/loads per-user API endpoint permissions (DB schema and migration remove author_id from user_api_endpoints); introduces Service.ModifyAPIOnlyUser and client/CLI support (fleetctl create --api-only no longer requires email/password); enforces validation and license-gating for api_endpoints; adds helpers for random email/password generation; and introduces extensive unit and integration tests covering free vs premium behaviors.

Possibly related PRs

  • PR 43194: Touches the API endpoints subsystem and embedded YAML loading used by endpoint validation and lookups.
  • PR 43077: Implements/adjusts user_api_endpoints table and initial handling that this change persists and queries per-user.
  • PR 40468: Modifies server/service/users.go logging and user creation flows that overlap service-layer changes in this PR.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Allow the creation of API-only users' directly and clearly describes the main change in the changeset, matching the primary objective.
Description check ✅ Passed The PR description includes related issues, changes summary, and completed checklist items, but is missing sections from the template like database migrations and security validation details.
Linked Issues check ✅ Passed The changeset implements all coding objectives: POST endpoint for API-only user creation [#42882], fleetctl changes removing email/password requirements [#42880], and PATCH/GET endpoints with api_endpoints support [#42884].
Out of Scope Changes check ✅ Passed All changes align with the linked issues: new API endpoints, CLI updates, database schema changes, migrations, and comprehensive test coverage are all within scope of enabling API-only user creation and management.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 42882_42880_42884_allow_creation_of_api_only_users

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@changes/42882-42880-42884-allow-creation-of-api-only-users`:
- Around line 1-3: The changelog currently lists POST /users/api_only, PATCH
/users/api_only/{id}, and CLI changes but omits the GET-side contract change —
update the release notes to also call out that the GET /users/{id} response now
includes the new api_endpoints field; mention the exact endpoint name GET
/users/{id} and the added response property api_endpoints so API consumers know
the response shape has changed.

In `@cmd/fleetctl/fleetctl/user.go`:
- Around line 127-150: When apiOnly is true the code currently ignores any SSO
or MFA flags; add an explicit validation before calling CreateAPIOnlyUser to
reject incompatible flag combinations by returning an error if --api-only is set
together with the SSO or MFA flags. Locate the apiOnly branch and check the
SSO/MFA flag variables (the same variables parsed for the non-API path, e.g.,
sso and mfa or their equivalents) and if either is set return a descriptive
fmt.Errorf explaining that --sso and --mfa cannot be used with --api-only; keep
the rest of the CreateAPIOnlyUser flow (including the sessionKey handling)
unchanged.

In
`@server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go`:
- Around line 28-29: The ALTER TABLE DDL built in the tx.Exec(fmt.Sprintf(...))
call that runs `ALTER TABLE user_api_endpoints DROP FOREIGN KEY %s` uses the raw
constraintName and can break if the FK identifier needs quoting; update the code
that constructs the SQL for DROP FOREIGN KEY in this migration (the
fmt.Sprintf/tx.Exec invocation referencing constraintName) to safely quote the
identifier with backticks and escape any backticks in constraintName before
interpolation so the resulting SQL uses `\`<escaped-constraint>\`` rather than
the raw value, preventing syntax errors and SQL injection risks.

In `@server/fleet/users.go`:
- Around line 60-62: The APIEndpoints field currently uses
`json:"api_endpoints,omitempty"`, which hides both nil and empty slices in
responses; update the struct field `APIEndpoints []APIEndpointRef` to either
remove `omitempty` so the field is always present (empty array for non-API-only
users) or change the type to a pointer `*[]APIEndpointRef` if you need explicit
nil vs empty-array semantics; adjust any serializers/tests that expect the
absent field accordingly and keep the field name `APIEndpoints` and type
`APIEndpointRef` consistent.

In `@server/service/integration_enterprise_test.go`:
- Around line 28591-28599: The test updates a user's teams but doesn't assert
that the previous global role was cleared; after the PATCH call that sets
"fleets" via s.DoJSON (using patchURL) and inspects respTeam (patchResp), add an
assertion that respTeam.User.GlobalRole (or respTeam.User.global_role in JSON)
is empty/nil to ensure the global role was removed when switching to team
(fleet) role.
- Around line 28675-28680: The test currently hard-codes adminID := uint(1) and
then calls s.DoJSON("/api/latest/fleet/users/%d", adminID) which couples the
test to seed data; instead, create a fresh regular user within the test (via the
existing user creation helper or an API POST to /api/latest/fleet/users),
capture the returned ID, and use that ID in the subsequent s.DoJSON GET; update
references to adminID and assertions on getAdminResp (type getUserResp and
fields User.APIOnly and User.APIEndpoints) to use the newly created user's ID so
the test no longer relies on seeded users.

In `@server/service/users.go`:
- Around line 249-268: Early returns in the user creation flow can exit before
authorization is marked checked, causing the transport to report an auth-skipped
error; before each early return in the GenerateRandomPwd error branch, the
viewer.FromContext failure branch, and the GenerateRandomEmail error branch,
call setAuthCheckedOnPreAuthErr(ctx) to mark auth as checked (i.e., invoke
setAuthCheckedOnPreAuthErr(ctx) just prior to returning the createUserResponse
in the error paths around GenerateRandomPwd, viewer.FromContext, and
GenerateRandomEmail).
- Around line 325-329: The authorization check in ModifyAPIOnlyUser currently
calls svc.authz.Authorize(ctx, &fleet.User{}, fleet.ActionWrite) before loading
the target user, which strips any team context and incorrectly denies
team-scoped updates; instead, first load the target user by calling the existing
user lookup (e.g., retrieve via svc.ds.User(...)/svc.UserByID) and then call
svc.authz.Authorize(ctx, loadedUser, fleet.ActionWrite) so the authorizer can
evaluate team scope; if you must keep the pre-check to prevent ID enumeration,
normalize the error semantics so callers receive the same 404/403 outcome
regardless of whether the user exists or authorization fails rather than
authorizing on an empty fleet.User.
- Around line 201-208: CreateUser currently only validates API endpoints when
p.APIOnly is true, allowing non-API-only creates to include p.APIEndpoints;
enforce the same invariant as ModifyUser by rejecting APIEndpoints when APIOnly
is absent or false. In the CreateUser path around the p.APIOnly /
validateAPIEndpointRefs logic, add a guard that if p.APIEndpoints is non-nil (or
non-empty) and (p.APIOnly == nil || !*p.APIOnly) you return the same
validation/error used by ModifyUser; keep using validateAPIEndpointRefs only for
the API-only branch and mirror ModifyUser’s error type/message to ensure
consistent behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b079f96d-2820-4342-a966-11e59800c47c

📥 Commits

Reviewing files that changed from the base of the PR and between 577fe75 and ec76cad.

📒 Files selected for processing (16)
  • changes/42882-42880-42884-allow-creation-of-api-only-users
  • cmd/fleetctl/fleetctl/user.go
  • cmd/fleetctl/fleetctl/users_test.go
  • server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go
  • server/datastore/mysql/schema.sql
  • server/datastore/mysql/users.go
  • server/fleet/service.go
  • server/fleet/users.go
  • server/mock/service/service_mock.go
  • server/service/client_users.go
  • server/service/handler.go
  • server/service/integration_core_test.go
  • server/service/integration_enterprise_test.go
  • server/service/users.go
  • server/utils.go
  • server/utils_test.go

@juan-fdz-hawa juan-fdz-hawa marked this pull request as ready for review April 13, 2026 12:19
@juan-fdz-hawa juan-fdz-hawa requested a review from a team as a code owner April 13, 2026 12:19
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

♻️ Duplicate comments (1)
cmd/fleetctl/fleetctl/user.go (1)

127-153: ⚠️ Potential issue | 🟡 Minor

--sso is still silently ignored in the --api-only path.

Line 127 returns before the normal SSO/password validation runs, so fleetctl user create --api-only --sso succeeds while discarding the caller's --sso request. Reject it alongside --mfa.

💡 Suggested fix
 			if apiOnly {
-				if mfa {
-					return errors.New("--mfa cannot be used with --api-only")
+				if sso || mfa {
+					return errors.New("--sso and --mfa cannot be used with --api-only")
 				}
 				sessionKey, err := client.CreateAPIOnlyUser(name, globalRole, teams)
 				if err != nil {
 					return fmt.Errorf("Failed to create user: %w", err)
 				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/fleetctl/fleetctl/user.go` around lines 127 - 153, In the api-only branch
of the user creation flow (the block that checks apiOnly in the function
handling the user create command in user.go), add validation to reject the --sso
flag just like --mfa is rejected: before calling client.CreateAPIOnlyUser(name,
globalRole, teams) check if sso (the parsed flag variable for SSO) is true and
return an error such as "--sso cannot be used with --api-only"; ensure this
validation lives alongside the existing mfa check so the command fails fast and
does not silently ignore SSO when apiOnly is set.
🧹 Nitpick comments (2)
server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go (1)

37-38: Wrap DROP COLUMN errors with operation context.

Line 38 returns the raw error; adding context makes migration failures easier to diagnose.

💡 Suggested tweak
-	_, err = tx.Exec(`ALTER TABLE user_api_endpoints DROP COLUMN author_id`)
-	return err
+	if _, err = tx.Exec(`ALTER TABLE user_api_endpoints DROP COLUMN author_id`); err != nil {
+		return fmt.Errorf("drop user_api_endpoints.author_id column: %w", err)
+	}
+	return nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go`
around lines 37 - 38, The migration returns the raw error from tx.Exec when
running ALTER TABLE user_api_endpoints DROP COLUMN author_id; change the error
handling in the migration function (where tx.Exec is called) to wrap the
returned error with contextual information (e.g., reference the operation "drop
author_id from user_api_endpoints" and include the original error) so failures
are easier to diagnose; locate the tx.Exec call that executes `ALTER TABLE
user_api_endpoints DROP COLUMN author_id` and replace the direct return of err
with a wrapped error using fmt.Errorf(..., %w) or errors.Wrap.
server/service/users_test.go (1)

1707-1722: Tighten the assertion so this test proves the api_only guard fired.

Right now it only checks InvalidArgumentError, so an unrelated validation failure would also satisfy the test. Consider asserting the error mentions api_only and that no save path is invoked.

♻️ Suggested test hardening
 	t.Run("cannot promote non-API user to API-only via api_only:true", func(t *testing.T) {
 		adminUser := newAdminTestUser(nil)
 		regularUser := newAdminTestUser(&adminTestUserOpts{id: 2, email: "regular@example.com", apiOnly: false})
 		ds, svc, ctx := setupAdminTestContext(t, adminUser)
 		setupModifyUserMocks(ds, regularUser)
+		ds.SaveUserFunc = func(ctx context.Context, u *fleet.User) error {
+			t.Fatal("SaveUser should not be called when api_only mutation is rejected")
+			return nil
+		}
 
 		_, err := svc.ModifyUser(ctx, regularUser.ID, fleet.UserPayload{APIOnly: new(true)})
 		require.Error(t, err)
 		var argErr *fleet.InvalidArgumentError
 		require.ErrorAs(t, err, &argErr)
+		require.Contains(t, err.Error(), "api_only")
 	})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/users_test.go` around lines 1707 - 1722, The test should not
only assert InvalidArgumentError from svc.ModifyUser but also ensure the
api_only guard triggered: update the test case that calls svc.ModifyUser(...,
fleet.UserPayload{APIOnly: new(false)}) to assert the returned error message
contains "api_only" (or the exact field name used in validation) and
additionally assert the mock datastore (ds) save/update method (e.g.,
SaveUser/UpdateUser) was not invoked—use the mock's "NotCalled" or expectation
checks set up in setupModifyUserMocks so the test proves no save path was
executed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/fleetctl/fleetctl/user.go`:
- Around line 40-43: Restore the conditional validation so that --email is
required unless --api-only is set: in the user creation command handler (the
action function that reads emailFlagName and apiOnlyFlagName), add a pre-run or
initial check that if the api-only flag is false/empty and the value for
emailFlagName is empty, return a user-facing error indicating email is required;
keep the flag definition non-required but enforce the conditional requirement
programmatically in the command's action/validate step (referencing
emailFlagName and apiOnlyFlagName to locate the values).

In `@server/service/integration_enterprise_test.go`:
- Around line 28761-28775: After creating the regular user (regularUserResp),
also call the list endpoint "GET /api/latest/fleet/users" and assert the created
user's entry has APIOnly false and APIEndpoints empty; specifically, add a GET
request similar to the existing one but storing into a list response (e.g.
getUsersResp) and locate the user by ID from regularUserResp.User.ID, then
require.False on that user's APIOnly and require.Empty on that user's
APIEndpoints to match the per-user GET assertions; use the same request helper
(s.DoJSON) and assertion style as the existing getAdminResp checks.
- Around line 28416-28429: The test case "more than 100 api_endpoints" currently
generates non-catalog paths in the eps slice (eps[i] =
map[string]any{"method":"GET","path":fmt.Sprintf("/api/v1/fleet/path/%d", i)})
which can trigger catalog validation errors instead of the intended max-length
error; update the generated paths in that test (and the similar case around the
other occurrence) to use a valid catalog endpoint path (for example
fmt.Sprintf("/api/v1/fleet/queries/%d" or another known catalog endpoint used
elsewhere in tests) so failures come from the api_endpoints length cap; keep the
same eps construction and only change the fmt.Sprintf path string in both
places.

In `@server/service/users.go`:
- Around line 44-57: The createUserEndpoint currently rejects req.APIEndpoints
but still forwards req.APIOnly into svc.CreateUser; update createUserEndpoint to
also reject API-only creation by checking req.APIOnly (e.g., if req.APIOnly !=
nil or true depending on its type) and return a createUserResponse with
fleet.NewInvalidArgumentError("api_only", "This endpoint does not accept
API-only user creation") while calling setAuthCheckedOnPreAuthErr(ctx) before
returning, instead of passing req.APIOnly into CreateUser; locate
createUserEndpoint, createUserRequest, req.APIOnly and svc.CreateUser to apply
this change.
- Around line 377-388: The invite signup endpoint createUserFromInviteEndpoint
currently blocks APIEndpoints but does not guard against the APIOnly field,
allowing API-only users to be created; add the same explicit check used
elsewhere (lines referenced in review) by inspecting req.APIOnly in
createUserFromInviteEndpoint, call setAuthCheckedOnPreAuthErr(ctx) and return a
createUserResponse with fleet.NewInvalidArgumentError("api_only", "This endpoint
does not accept api_only users") (or similar message) so VerifyInviteCreate() /
NewUser() cannot create API-only accounts from this transport; ensure the check
mirrors the pattern used at the other transport guard
(setAuthCheckedOnPreAuthErr + createUserResponse error) and reference req,
APIOnly, createUserFromInviteEndpoint, createUserResponse,
setAuthCheckedOnPreAuthErr, and NewUser when making the change.

---

Duplicate comments:
In `@cmd/fleetctl/fleetctl/user.go`:
- Around line 127-153: In the api-only branch of the user creation flow (the
block that checks apiOnly in the function handling the user create command in
user.go), add validation to reject the --sso flag just like --mfa is rejected:
before calling client.CreateAPIOnlyUser(name, globalRole, teams) check if sso
(the parsed flag variable for SSO) is true and return an error such as "--sso
cannot be used with --api-only"; ensure this validation lives alongside the
existing mfa check so the command fails fast and does not silently ignore SSO
when apiOnly is set.

---

Nitpick comments:
In
`@server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go`:
- Around line 37-38: The migration returns the raw error from tx.Exec when
running ALTER TABLE user_api_endpoints DROP COLUMN author_id; change the error
handling in the migration function (where tx.Exec is called) to wrap the
returned error with contextual information (e.g., reference the operation "drop
author_id from user_api_endpoints" and include the original error) so failures
are easier to diagnose; locate the tx.Exec call that executes `ALTER TABLE
user_api_endpoints DROP COLUMN author_id` and replace the direct return of err
with a wrapped error using fmt.Errorf(..., %w) or errors.Wrap.

In `@server/service/users_test.go`:
- Around line 1707-1722: The test should not only assert InvalidArgumentError
from svc.ModifyUser but also ensure the api_only guard triggered: update the
test case that calls svc.ModifyUser(..., fleet.UserPayload{APIOnly: new(false)})
to assert the returned error message contains "api_only" (or the exact field
name used in validation) and additionally assert the mock datastore (ds)
save/update method (e.g., SaveUser/UpdateUser) was not invoked—use the mock's
"NotCalled" or expectation checks set up in setupModifyUserMocks so the test
proves no save path was executed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ebefe68c-6886-41bb-b93d-cd37c309b22d

📥 Commits

Reviewing files that changed from the base of the PR and between ec76cad and 5edd9cf.

📒 Files selected for processing (11)
  • changes/42882-42880-42884-allow-creation-of-api-only-users
  • cmd/fleetctl/fleetctl/user.go
  • server/api_endpoints/api_endpoints.go
  • server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go
  • server/datastore/mysql/users.go
  • server/fleet/service.go
  • server/service/client_users.go
  • server/service/integration_core_test.go
  • server/service/integration_enterprise_test.go
  • server/service/users.go
  • server/service/users_test.go
✅ Files skipped from review due to trivial changes (2)
  • server/api_endpoints/api_endpoints.go
  • server/datastore/mysql/users.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • changes/42882-42880-42884-allow-creation-of-api-only-users
  • server/service/client_users.go
  • server/service/integration_core_test.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/service/users.go (1)

79-91: Consider simplifying the validation condition for clarity.

The nested condition is functionally correct but hard to parse at a glance. The intent is: reject if requireNonEmpty is true, OR if an explicit empty array was provided (i.e., refs != nil but empty).

♻️ Optional: Clearer condition structure
 func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef, requireNonEmpty bool) error {
-	if refs == nil || len(*refs) == 0 {
-		if requireNonEmpty || refs != nil {
-			return ctxerr.Wrap(
-				ctx,
-				fleet.NewInvalidArgumentError(
-					"api_endpoints",
-					"At least one API endpoint must be specified for API only users",
-				),
-			)
-		}
-		return nil
-	}
+	// If field is not provided and we don't require it, nothing to validate.
+	if refs == nil && !requireNonEmpty {
+		return nil
+	}
+	// Otherwise, require at least one entry (reject nil when required, or explicit empty array always).
+	if refs == nil || len(*refs) == 0 {
+		return ctxerr.Wrap(
+			ctx,
+			fleet.NewInvalidArgumentError(
+				"api_endpoints",
+				"At least one API endpoint must be specified for API only users",
+			),
+		)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/users.go` around lines 79 - 91, The validation logic in
validateAPIEndpointRefs is correct but hard to read; split the nil check and the
empty-slice check for clarity: first, if refs == nil then return an error only
when requireNonEmpty is true otherwise return nil; second, if len(*refs) == 0
then always return the same
ctxerr.Wrap(fleet.NewInvalidArgumentError("api_endpoints", "...")) error (since
refs is non-nil but empty); keep the same error message and use the same ctx,
refs, and requireNonEmpty symbols so callers behave identically.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/service/users.go`:
- Around line 79-91: The validation logic in validateAPIEndpointRefs is correct
but hard to read; split the nil check and the empty-slice check for clarity:
first, if refs == nil then return an error only when requireNonEmpty is true
otherwise return nil; second, if len(*refs) == 0 then always return the same
ctxerr.Wrap(fleet.NewInvalidArgumentError("api_endpoints", "...")) error (since
refs is non-nil but empty); keep the same error message and use the same ctx,
refs, and requireNonEmpty symbols so callers behave identically.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 428d8420-6d1e-498a-b98e-c31a7de7dc24

📥 Commits

Reviewing files that changed from the base of the PR and between 5edd9cf and bfd330f.

📒 Files selected for processing (3)
  • server/service/integration_core_test.go
  • server/service/integration_enterprise_test.go
  • server/service/users.go
✅ Files skipped from review due to trivial changes (1)
  • server/service/integration_core_test.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
server/service/integration_core_test.go (1)

432-503: Tighten negative-case assertions in TestCreateAPIOnlyUser.

Right now failures assert status only. Please also assert error content/code so a wrong validation path returning the same status doesn’t pass silently.

♻️ Suggested refactor
 	cases := []struct {
 		name       string
 		body       map[string]any
 		wantStatus int
+		wantErrSubstr string
 		verify     func(t *testing.T, resp createAPIOnlyUserResponse)
 	}{
 		{
 			name:       "missing name",
 			body:       map[string]any{"global_role": "observer"},
 			wantStatus: http.StatusUnprocessableEntity,
+			wantErrSubstr: "name",
 		},
@@
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 			var resp createAPIOnlyUserResponse
 			s.DoJSON("POST", "/api/latest/fleet/users/api_only", tc.body, tc.wantStatus, &resp)
+			if tc.wantErrSubstr != "" {
+				require.Contains(t, strings.ToLower(resp.Err), strings.ToLower(tc.wantErrSubstr))
+			}
 			if tc.verify != nil {
 				tc.verify(t, resp)
 			}
 		})
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/integration_core_test.go` around lines 432 - 503, The negative
tests in the table only assert HTTP status; update TestCreateAPIOnlyUser so each
negative-case entry (the cases slice used with s.DoJSON and
createAPIOnlyUserResponse) also includes expected error details (e.g., an
expected error code or message field) and assert those after the request; for
failing cases call s.DoJSON (or a variant) to unmarshal into the API error
response struct (createAPIOnlyUserResponse currently used for success) or a
dedicated error struct, then assert the error.code/error.message equals the
expected value for that case (add fields like wantErrorCode/wantErrorMessage to
the case entries and check them when tc.verify == nil) to ensure the test
validates the error payload as well as the HTTP status.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/service/users.go`:
- Around line 353-378: Add an upfront check in ModifyAPIOnlyUser to ensure the
loaded target user is actually API-only before calling ModifyUser: after
retrieving target via svc.ds.UserByID and after authorization, verify
target.APIOnly (or the equivalent field) is true and if not return a contextual
InvalidArgument error (e.g. ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("id",
"user is not an API-only user"))) so callers of the /users/api_only/{id}
endpoint get a clear, immediate error instead of the downstream "cannot change
api_only status" failure.

---

Nitpick comments:
In `@server/service/integration_core_test.go`:
- Around line 432-503: The negative tests in the table only assert HTTP status;
update TestCreateAPIOnlyUser so each negative-case entry (the cases slice used
with s.DoJSON and createAPIOnlyUserResponse) also includes expected error
details (e.g., an expected error code or message field) and assert those after
the request; for failing cases call s.DoJSON (or a variant) to unmarshal into
the API error response struct (createAPIOnlyUserResponse currently used for
success) or a dedicated error struct, then assert the error.code/error.message
equals the expected value for that case (add fields like
wantErrorCode/wantErrorMessage to the case entries and check them when tc.verify
== nil) to ensure the test validates the error payload as well as the HTTP
status.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 38dcd331-2ff3-47d7-80a9-575b756941e0

📥 Commits

Reviewing files that changed from the base of the PR and between bfd330f and 2e05036.

📒 Files selected for processing (2)
  • server/service/integration_core_test.go
  • server/service/users.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
server/service/users.go (2)

353-378: ⚠️ Potential issue | 🟡 Minor

Add upfront check that target user is API-only.

The method correctly authorizes against the loaded target user (addressing the team-scoped auth concern). However, it doesn't verify that the target is an API-only user before delegating to ModifyUser. If called with a regular user's ID, it will fail with "cannot change api_only status of a user" (line 692-694), which is confusing for callers using the /users/api_only/{id} endpoint.

Suggested fix
 func (svc *Service) ModifyAPIOnlyUser(ctx context.Context, userID uint, p fleet.UserPayload) (*fleet.User, error) {
 	target, err := svc.ds.UserByID(ctx, userID)
 	if err != nil {
 		setAuthCheckedOnPreAuthErr(ctx)
 		return nil, ctxerr.Wrap(ctx, err)
 	}
 	if err := svc.authz.Authorize(ctx, target, fleet.ActionWrite); err != nil {
 		return nil, err
 	}

+	if !target.APIOnly {
+		return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("id", "user is not an API-only user"))
+	}
+
 	vc, ok := viewer.FromContext(ctx)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/users.go` around lines 353 - 378, ModifyAPIOnlyUser currently
doesn't verify the loaded target is an API-only user and can produce confusing
errors downstream; after loading target via svc.ds.UserByID and before
authorizing/early-return checks (or immediately after authorization), check the
target's API-only flag (e.g., target.APIOnly or equivalent field) and return a
clear invalid-argument error (use fleet.NewInvalidArgumentError("id", "<clear
message>")) if the target is not API-only, so callers of the
/users/api_only/{id} endpoint get a meaningful error instead of the generic
ModifyUser message.

44-57: ⚠️ Potential issue | 🟡 Minor

Reject api_only on the generic create endpoint.

The guard only rejects api_endpoints but still allows api_only: true to pass through to CreateUser, where it would successfully create an API-only user (with auto-added wildcard endpoints). This bypasses the dedicated /users/api_only endpoint and creates inconsistency in the API surface.

Suggested fix
 func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
 	req := request.(*createUserRequest)

+	if req.APIOnly != nil {
+		setAuthCheckedOnPreAuthErr(ctx)
+		return createUserResponse{
+			Err: fleet.NewInvalidArgumentError(
+				"api_only",
+				"This endpoint does not accept API only values",
+			),
+		}, nil
+	}
+
 	if req.APIEndpoints != nil {
 		setAuthCheckedOnPreAuthErr(ctx)
 		return createUserResponse{
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/service/users.go` around lines 44 - 57, The generic createUserEndpoint
currently only rejects APIEndpoints but not api_only; update createUserEndpoint
to also detect and reject the api_only flag on req (e.g., check req.APIOnly or
req.ApiOnly) by calling setAuthCheckedOnPreAuthErr(ctx) and returning a
createUserResponse with fleet.NewInvalidArgumentError("api_only", "Use the
/users/api_only endpoint to create API-only users") so api_only requests are
routed to the dedicated Create API-only flow instead of passing through to
svc.CreateUser.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@server/service/users.go`:
- Around line 353-378: ModifyAPIOnlyUser currently doesn't verify the loaded
target is an API-only user and can produce confusing errors downstream; after
loading target via svc.ds.UserByID and before authorizing/early-return checks
(or immediately after authorization), check the target's API-only flag (e.g.,
target.APIOnly or equivalent field) and return a clear invalid-argument error
(use fleet.NewInvalidArgumentError("id", "<clear message>")) if the target is
not API-only, so callers of the /users/api_only/{id} endpoint get a meaningful
error instead of the generic ModifyUser message.
- Around line 44-57: The generic createUserEndpoint currently only rejects
APIEndpoints but not api_only; update createUserEndpoint to also detect and
reject the api_only flag on req (e.g., check req.APIOnly or req.ApiOnly) by
calling setAuthCheckedOnPreAuthErr(ctx) and returning a createUserResponse with
fleet.NewInvalidArgumentError("api_only", "Use the /users/api_only endpoint to
create API-only users") so api_only requests are routed to the dedicated Create
API-only flow instead of passing through to svc.CreateUser.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cabdbb19-9ec5-4404-93c0-cc7b887e6c17

📥 Commits

Reviewing files that changed from the base of the PR and between 2e05036 and 6687288.

📒 Files selected for processing (1)
  • server/service/users.go

Copy link
Copy Markdown
Member

@nulmete nulmete left a comment

Choose a reason for hiding this comment

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

LGTM overall 👍 I only have a couple clarifying questions, nothing blocking.
Haven't looked at tests yet and I saw a change was just pushed before I submitted my review 😬

Copy link
Copy Markdown
Member

@lucasmrod lucasmrod left a comment

Choose a reason for hiding this comment

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

LGTM! Left some questions/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Given this is unreleased, do we need a separate migration? (Why not fix the existing migration for the feature)

}

fmt.Fprintln(c.App.Writer, "Successfully created new user!")
if appCfg, cfgErr := client.GetAppConfig(); cfgErr == nil &&
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we print somthing if cfgErr != nil?

return err
}

if user.APIEndpoints != nil {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if user.APIEndpoints != nil {
if user.APIOnly && user.APIEndpoints != nil {

// - Field absent → Present=false: no change to the user's current endpoints.
// - Field is null → Present=true, Value=nil: clear all entries (full access).
// - Field is an array → Present=true, Value=[...]: replace with specific entries.
type OptionalAPIEndpoints struct {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IIRC we have optjson package for this use case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

But I think this is a-ok as is, it's clear.

ue.GET("/api/_version_/fleet/users", listUsersEndpoint, listUsersRequest{})
ue.POST("/api/_version_/fleet/users/admin", createUserEndpoint, createUserRequest{})
ue.POST("/api/_version_/fleet/users/api_only", createAPIOnlyUserEndpoint, createAPIOnlyUserRequest{})
ue.PATCH("/api/_version_/fleet/users/api_only/{id:[0-9]+}", modifyAPIOnlyUserEndpoint, modifyAPIOnlyUserRequest{})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Asking here instead of Slack. Why do we need a new PATCH instead of using the existing one for users.

type createAPIOnlyUserRequest struct {
Name *string `json:"name,omitempty"`
GlobalRole *string `json:"global_role,omitempty"`
Teams *[]fleet.UserTeam `json:"teams,omitempty" renameto:"fleets"`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need the teams alias on new endpoints?

if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return nil, ctxerr.Wrap(ctx, err)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe check that target is indeed APIOnly?

Comment on lines +664 to +665
if p.APIOnly != nil && *p.APIOnly && len(teamRoles) > 0 {
return nil, fleet.ErrMissingLicense
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same as the other place, this might be caught already by fleet.PremiumRolesPresent.

Copy link
Copy Markdown
Contributor Author

@juan-fdz-hawa juan-fdz-hawa Apr 14, 2026

Choose a reason for hiding this comment

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

fleet.PremiumRolesPresent only validates against premium roles which is a subset of all the roles that are available, what we are checking here is that if you are creating an API only user you are not allowed to use team roles (regardless of whether they are premium or not).

Comment on lines +685 to +689
// Changing endpoint permissions is a privileged operation — same level as
// changing roles. This prevents an API-only user from expanding their own access.
if err := svc.authz.Authorize(ctx, user, fleet.ActionWriteRole); err != nil {
return nil, err
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good idea. I will be checking all of this as part of #42887

server/utils.go Outdated
)

// GenerateRandomEmail generates a random email using baseEmail as the base.
// For example: GenerateRandomEmail('juan@fleetdm.com') -> 'juan+somerandomtext@fleetdm.com'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: Please remove the fleetdm.com address (IIRC we don't want those to reduce spam).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants