Skip to content

Distinguish API-only endpoint-restriction 403s from role-based permission denials - #50816

Open
lukeheath wants to merge 3 commits into
mainfrom
50813-distinguish-api-only-endpoint-restriction-403s
Open

Distinguish API-only endpoint-restriction 403s from role-based permission denials#50816
lukeheath wants to merge 3 commits into
mainfrom
50813-distinguish-api-only-endpoint-restriction-403s

Conversation

@lukeheath

@lukeheath lukeheath commented Aug 7, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #50813

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

Details

apiOnlyEndpointCheck returned the same generic fleet.NewPermissionError("forbidden") for both endpoint-restriction failure modes, making these 403s indistinguishable from role-based permission denials in both the response body and server logs.

This PR makes endpoint-restriction denials identifiable in two places:

403 response body. The denial now returns a UserMessageError wrapping the permission error, so the message survives encoding (the error encoder discards PermissionError messages and renders a generic "Permission Denied" body):

{
  "message": "Forbidden",
  "errors": [
    { "name": "base", "reason": "endpoint not permitted for this API-only user" }
  ],
  "uuid": "..."
}

Role-based denials are unchanged ("forbidden"). The restriction list is not secret from the caller (they already hold a valid token), so naming the gate discloses nothing.

Server logs. The denial is surfaced on the request log line at info level (role-based 403s log at debug, which is how these went unnoticed) with a denial marker, the reason, and the matched route template. User email and request method were already on the line:

level=INFO user=slackbot@example.com method=GET uri=/api/latest/fleet/labels/foo denied_by=api_only_endpoint_restriction denial_reason="endpoint not in user's allowed API endpoints" route=/api/{fleetversion:(?:v1|2022-04|latest)}/fleet/labels/{name} err="endpoint not permitted for this API-only user"

The two failure modes (route not in the API endpoint catalog vs. route not on the user's allowlist) get distinct denial_reason values in the log; the response body message is the same for both.

Verified the encoded response by running the error through the real endpointer.EncodeError path. Existing integration tests assert only the 403 status, which is unchanged.

Summary by CodeRabbit

  • Bug Fixes
    • API-only endpoint restriction denials now return a distinct 403 message.
    • Catalog, allowlist, and other restriction denials are clearly differentiated.
    • Denials are logged at info level with the affected route and reason, improving visibility into access decisions.
    • Enterprise responses now consistently include the restriction-specific error message.
  • Documentation
    • Added a changelog entry describing the updated 403 response and logging behavior.

Requests denied by an API-only user's endpoint allowlist now return a
distinct 403 message and log the route template and denial reason at
info level, so these denials are identifiable in server logs and error
responses instead of looking like role-based permission denials.

Fixes #50813
@lukeheath
lukeheath requested a review from a team as a code owner August 7, 2026 21:21
Copilot AI lite review requested due to automatic review settings August 7, 2026 21:21
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c497328f-e8e5-4991-ba25-9fbb3a32e087

📥 Commits

Reviewing files that changed from the base of the PR and between adf5d6a and 7d60f3e.

📒 Files selected for processing (3)
  • server/service/integration_enterprise_test.go
  • server/service/middleware/auth/api_only.go
  • server/service/middleware/auth/api_only_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/service/middleware/auth/api_only.go
  • server/service/middleware/auth/api_only_test.go

Walkthrough

API-only endpoint restriction denials now return a distinct 403 message. Catalog, allowlist, and missing-context failures use specialized denial handling. The middleware logs the route and denial reason at info level. Middleware and enterprise integration tests verify the response message and structured logging fields. A changelog entry documents the behavior.

Possibly related PRs

  • fleetdm/fleet#49477: Modifies API-only endpoint restriction behavior in the same middleware and tests.
  • fleetdm/fleet#49607: Extends API-only endpoint restriction handling and 403 denial behavior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: distinguishing API-only endpoint-restriction 403s from role-based denials.
Description check ✅ Passed The description identifies the issue, explains the behavior changes, documents testing, and includes the required changes file and automated-test checklist items.
Linked Issues check ✅ Passed The changes satisfy issue #50813 by distinguishing restriction denials in responses and logs, including distinct reasons and route context.
Out of Scope Changes check ✅ Passed The changelog, middleware changes, unit tests, and integration assertions directly support the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 50813-distinguish-api-only-endpoint-restriction-403s

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.45.0)
server/service/integration_enterprise_test.go

ast-grep timed out on this file


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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

This PR improves debuggability of API-only user endpoint restriction denials by making them distinguishable from role-based permission denials, both in the 403 response body and in request logging.

Changes:

  • Return a 403 UserMessageError (wrapping a PermissionError) for endpoint-restriction denials so the denial reason survives JSON encoding.
  • Force request logging to INFO and attach structured extras (denied_by, denial_reason, route) when endpoint restrictions deny a request.
  • Update/extend unit tests to assert the new error type/message and the logging context fields.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.

File Description
server/service/middleware/auth/api_only.go Adds distinct endpoint-restriction denial error + info-level log extras for API-only restrictions.
server/service/middleware/auth/api_only_test.go Updates assertions to the new UserMessageError and adds a test for log level/extras on denial.
changes/50813-distinguish-api-only-endpoint-restriction-403s User-visible changes entry (content excluded from review output by policy).
Files excluded by content exclusion policy (1)
  • changes/50813-distinguish-api-only-endpoint-restriction-403s

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 57 to 64
requestMethod, _ := ctx.Value(kithttp.ContextKeyRequestMethod).(string)
routeTemplate, _ := eu.RouteTemplateFromContext(ctx)

fp := fleet.NewAPIEndpointFromTpl(requestMethod, routeTemplate).Fingerprint()

if !isInCatalog(fp) {
return nil, permissionDenied(ctx)
return nil, endpointRestrictionDenied(ctx, routeTemplate, "endpoint not in API endpoint catalog")
}

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 7d60f3e. The middleware now fails closed with a distinct denial_reason ("request method or route template missing from request context") before computing the fingerprint, instead of reporting a misleading catalog miss with an empty route. Covered by a new case in the log-extras subtest.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.53%. Comparing base (f654fe9) to head (7d60f3e).
⚠️ Report is 10 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #50816   +/-   ##
=======================================
  Coverage   68.52%   68.53%           
=======================================
  Files        3977     3977           
  Lines      256094   256153   +59     
  Branches    13658    13658           
=======================================
+ Hits       175489   175549   +60     
+ Misses      64987    64986    -1     
  Partials    15618    15618           
Flag Coverage Δ
backend 69.63% <100.00%> (+<0.01%) ⬆️

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

☔ View full report in Codecov by Harness.
📢 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.

@lukeheath

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/service/middleware/auth/api_only_test.go`:
- Around line 51-62: Extend the endpoint restriction denial tests around
requireEndpointRestrictionDenied with a request-level assertion that executes
the middleware flow and verifies the HTTP response status is
http.StatusForbidden and the response body contains
EndpointRestrictionDeniedMessage. Keep the existing error-level assertions
unchanged and use the established request/response test helpers.
🪄 Autofix

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 Plus

Run ID: 860a921b-a5ec-4d9b-860a-06c03ef42f29

📥 Commits

Reviewing files that changed from the base of the PR and between 9a2d7f2 and adf5d6a.

📒 Files selected for processing (3)
  • changes/50813-distinguish-api-only-endpoint-restriction-403s
  • server/service/middleware/auth/api_only.go
  • server/service/middleware/auth/api_only_test.go

Comment on lines +51 to +62
// requireEndpointRestrictionDenied asserts that err is the 403 returned when an
// API-only user's endpoint restrictions deny a request: a UserMessageError
// carrying EndpointRestrictionDeniedMessage, distinguishable from a role-based
// permission denial.
func requireEndpointRestrictionDenied(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
var umErr *fleet.UserMessageError
require.ErrorAs(t, err, &umErr)
require.Equal(t, http.StatusForbidden, umErr.StatusCode())
require.Equal(t, EndpointRestrictionDeniedMessage, umErr.UserMessage())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the changed test before inspecting encoder-related call sites.
ast-grep outline server/service/middleware/auth/api_only_test.go --items all

# Locate the error type, its encoder, and request-level tests that cover it.
rg -n -C 5 --type go \
  'NewUserMessageError|UserMessageError|UserMessage\(\)|StatusCode\(\)|PermissionError' \
  server

Repository: fleetdm/fleet

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== api_only_test.go relevant lines =="
sed -n '1,140p' server/service/middleware/auth/api_only_test.go

echo
echo "== endpointor error encoding relevant lines =="
sed -n '90,215p' server/platform/endpointer/transport_error.go

echo
echo "== apiOnlyEndpointRestrictionDenied identifier sources =="
rg -n -C 8 --type go 'apiOnlyEndpointRestrictionDenied|EndpointRestrictionDeniedMessage|requireEndpointRestrictionDenied|NewUserMessageError\(.*EndpointRestrictionDenied' server || true

echo
echo "== request-level tests mentioning 403 and restrictive messages =="
rg -n --type go 'EndpointRestrictionDeniedMessage|requireEndpointRestrictionDenied|StatusCode\(\) == http\.StatusForbidden|require\.Equal\(t, http\.StatusForbidden' server/service server/platform || true

Repository: fleetdm/fleet

Length of output: 30407


Add a request-level assertion for endpoint restriction denials.

requireEndpointRestrictionDenied only checks the error returned by the middleware. Add a real HTTP response assertion that this error encodes http.StatusForbidden with EndpointRestrictionDeniedMessage in the request flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/middleware/auth/api_only_test.go` around lines 51 - 62, Extend
the endpoint restriction denial tests around requireEndpointRestrictionDenied
with a request-level assertion that executes the middleware flow and verifies
the HTTP response status is http.StatusForbidden and the response body contains
EndpointRestrictionDeniedMessage. Keep the existing error-level assertions
unchanged and use the established request/response test helpers.

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.

Added in 7d60f3e: a unit test (TestEndpointRestrictionDeniedEncoding) pins the encoded 403 response (status + message in body) through the real endpointer.EncodeError path, and TestAPIOnlyUserEndpointMiddleware now asserts the response body contains EndpointRestrictionDeniedMessage on both denial paths in the full request flow.

…ody test coverage

- Fail closed with a distinct denial_reason when the request method or
  mux route template is missing from context, instead of reporting a
  misleading catalog miss with an empty route.
- Pin the encoded 403 response (status + distinct message) at the unit
  level and assert the response body in the endpoint middleware
  integration test.
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.

Distinguish API-only endpoint-restriction 403s from role-based permission denials

3 participants