Skip to content

fix(security): enforce AuthUser at the router layer, not just middleware (closes #60)#178

Closed
cristim wants to merge 1 commit into
feat/multicloud-web-frontendfrom
fix/issue-60-router-authuser
Closed

fix(security): enforce AuthUser at the router layer, not just middleware (closes #60)#178
cristim wants to merge 1 commit into
feat/multicloud-web-frontendfrom
fix/issue-60-router-authuser

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented Apr 28, 2026

Summary

Closes #60 (P1, severity:high, all-users impact).

Router.Route previously checked the route's Auth level only when it equalled AuthAdmin. Routes declared as Auth: AuthUser (/api/auth/logout, /api/auth/me, /api/auth/profile, /api/auth/change-password, /api/api-keys/*, /api/commitment-options, /api/federation/iac) fell through the if-block with no router-level enforcement. The endpoints were still protected by validateSecurity → authenticate middleware, but a future refactor that reorders middleware or adds a path bypassing validateSecurity would silently expose every AuthUser endpoint without any error at the router boundary.

Fix

Replace the if route.Auth == AuthAdmin branch with a switch that dispatches:

Auth Router-layer check
AuthAdmin h.requireAdmin (admin API key OR admin-role session) — unchanged
AuthUser new h.requireUser (admin API key OR any valid session) — new
AuthPublic no check — handler runs directly

New requireUser mirrors requireAdmin but does not require the admin role; missing/invalid auth returns 401 via ClientError before the handler runs. AuthLevel doc comment now explicitly describes the router-layer enforcement contract.

Tests

5 new router-auth tests pin the contract:

Test Asserts
TestRouter_AuthUser_RejectsMissingAuth 401 when no Authorization header is present
TestRouter_AuthUser_RejectsInvalidSession 401 when Bearer token is invalid
TestRouter_AuthUser_AcceptsValidSession Handler runs when a valid session is present
TestRouter_AuthUser_AcceptsAdminAPIKey Admin API key satisfies AuthUser (admins are users)
TestRouter_AuthPublic_NoAuthCheck AuthPublic still runs with no auth (guard against switch's default branch accidentally tightening)

All existing internal/api tests still pass.

Behavioural impact

No behavioural change for routes already passing through validateSecurity — the router-layer check is now belt-and-braces. Routes that bypass validateSecurity for any reason (a future test runner, a misconfigured handler chain, a path that gets added to isPublicEndpoint by mistake) are now protected at the router boundary.

🤖 Generated with claude-flow

Summary by CodeRabbit

Release Notes

  • Security Improvements
    • Enhanced authentication enforcement for user-level access with Bearer token support
    • Improved validation of authenticated sessions before processing requests

Closes #60.

`Router.Route` previously checked the route's Auth level only when it
equalled `AuthAdmin`. Routes declared as `Auth: AuthUser` (`/api/auth/logout`,
`/api/auth/me`, `/api/auth/profile`, `/api/auth/change-password`,
`/api/api-keys/*`, `/api/commitment-options`, `/api/federation/iac`) fell
through the if-block with no router-level enforcement. The endpoints
were still protected by `validateSecurity → authenticate` middleware,
but a future refactor that reorders middleware or adds a path
bypassing `validateSecurity` would silently expose every AuthUser
endpoint without any error at the router boundary.

Fix: replace the `if route.Auth == AuthAdmin` branch with a switch that
dispatches AuthAdmin → `requireAdmin`, AuthUser → new `requireUser`,
AuthPublic → no router-level check. New `requireUser` mirrors
`requireAdmin` but does not require the admin role — admin API key OR
any valid session passes; missing/invalid auth returns 401 via
ClientError before the handler runs.

Updated the AuthLevel doc comment so the router-layer enforcement
contract is explicit.

Tests: 5 new router-auth tests pin the contract:
  - AuthUser rejects missing Authorization header (401).
  - AuthUser rejects invalid Bearer token (401).
  - AuthUser accepts a valid session.
  - AuthUser accepts an admin API key (admins are users).
  - AuthPublic still runs with no auth (guard against the switch's
    default branch tightening accidentally).

No behavioural change for routes that were already passing through
`validateSecurity` — the router-layer check is now belt-and-braces.
Routes that bypass validateSecurity for any reason (e.g., a future
test runner, a misconfigured handler chain) are now protected at the
router boundary.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 28, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

📝 Walkthrough

Walkthrough

A new requireUser helper enforces authenticated user-level access in the router layer. The router's auth logic switches from only checking AuthAdmin to explicitly handling both AuthAdmin and AuthUser routes, validating sessions independently of middleware ordering.

Changes

Cohort / File(s) Summary
Router Authentication Enforcement
internal/api/middleware.go, internal/api/router.go
Introduces requireUser method that authenticates via admin API key (stateless) or Bearer token session validation, returning 401 for missing/invalid authorization. Router switches from single if to explicit switch statement handling AuthAdmin, AuthUser, and AuthPublic routes with corresponding enforcement and updated documentation.
Router Authentication Tests
internal/api/router_auth_test.go
New test file covering five scenarios: rejection with 401 when no Authorization header, rejection with 401 on invalid session, acceptance with valid session, acceptance with admin API key, and public route access without auth headers.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Router
    participant AdminKey
    participant AuthService
    participant Handler

    Client->>Router: HTTP Request + Authorization Header
    Router->>Router: Check route.Auth level

    alt AuthUser Route
        Router->>AdminKey: checkAdminAPIKey()
        alt Admin key valid
            AdminKey-->>Router: Session OK
        else Admin key missing/invalid
            Router->>AuthService: ValidateSession(Bearer Token)
            alt Valid session
                AuthService-->>Router: Session + User Info
            else Invalid/expired token
                Router-->>Client: 401 ClientError
            end
        end
        Router->>Handler: Invoke handler with context
        Handler-->>Client: Response
    else AuthAdmin Route
        Router->>AdminKey: requireAdmin (existing)
        AdminKey-->>Router: Admin validation
    else AuthPublic Route
        Router->>Handler: Invoke handler directly
        Handler-->>Client: Response
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A rabbit hops down the router's lane,
With requireUser standing guard like rain,
No sneaky routes slip through the gate—
Authentication locks the fate!
Security checks before handlers play,
The middleware doesn't sway! 🔐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: enforcing AuthUser at the router layer rather than relying solely on middleware, with issue reference.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from #60: router-layer enforcement for AuthUser returning 401 on invalid/missing auth, new requireUser helper, switch-based auth logic, documentation updates, and comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are in-scope: requireUser helper, Router.Route switch refactor, AuthLevel documentation, and router-auth tests directly address the linked issue #60 requirements without unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ 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 fix/issue-60-router-authuser

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

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@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: 3

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

Inline comments:
In `@internal/api/middleware.go`:
- Around line 220-239: The requireUser function only handles admin API keys and
bearer tokens but must also accept user API keys like authenticate does; after
calling extractAPIKey and checkAdminAPIKey, add the same user-API-key branch
authenticate uses (e.g., call the existing helper used by authenticate such as
h.checkUserAPIKey or h.auth.ValidateAPIKey) and if it validates return a Session
with Role "user" and the associated UserID, then continue to the bearer token
path; ensure this user-API-key check runs before falling back to
extractBearerToken/ValidateSession so valid user-key requests are accepted.

In `@internal/api/router_auth_test.go`:
- Around line 53-92: In both TestRouter_AuthUser_RejectsInvalidSession and
TestRouter_AuthUser_AcceptsValidSession, ensure the mock expectations on
MockAuth are asserted after exercising the Router: call
mockAuth.AssertExpectations(t) (or mockAuth.AssertExpectations) at the end of
each test so the ValidateSession expectation set with
mockAuth.On("ValidateSession", ...) is verified; this ensures MockAuth's
ValidateSession call actually occurred for the Handler/Router code paths.

In `@internal/api/router.go`:
- Around line 245-256: The switch over route.Auth
(AuthAdmin/AuthUser/AuthPublic) currently lacks a default so unknown auth values
fall through and run the handler; update the switch in the router to include a
default branch that fails closed by returning an authorization error (e.g., a
descriptive error like "unsupported auth level" or a forbidden error) instead of
allowing execution, and ensure this default returns from the surrounding
function the same way r.h.requireAdmin/ r.h.requireUser errors are returned so
no handler runs for unknown auth values.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: c867d377-315f-4cd5-af3b-4846dca710c7

📥 Commits

Reviewing files that changed from the base of the PR and between 3e655cb and 5f57132.

📒 Files selected for processing (3)
  • internal/api/middleware.go
  • internal/api/router.go
  • internal/api/router_auth_test.go

Comment on lines +220 to +239
func (h *Handler) requireUser(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) {
// Admin API key first (stateless).
apiKey := extractAPIKey(req)
if h.checkAdminAPIKey(apiKey) {
return &Session{Role: "admin", UserID: "admin-api-key"}, nil
}

if h.auth == nil {
return nil, fmt.Errorf("authentication service not configured")
}

token := h.extractBearerToken(req)
if token == "" {
return nil, NewClientError(401, "no authorization token provided")
}

session, err := h.auth.ValidateSession(ctx, token)
if err != nil || session == nil {
return nil, NewClientError(401, "invalid session")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

AuthUser router enforcement is missing the user API key path.

requireUser currently allows only admin API key or Bearer session, while authenticate also accepts user API keys. This creates inconsistent auth semantics and can reject valid AuthUser requests that authenticate via user API key.

Suggested fix
func (h *Handler) requireUser(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) {
	// Admin API key first (stateless).
	apiKey := extractAPIKey(req)
	if h.checkAdminAPIKey(apiKey) {
		return &Session{Role: "admin", UserID: "admin-api-key"}, nil
	}
+
+	// User API key (stateless).
+	if h.checkUserAPIKey(ctx, apiKey) {
+		return &Session{Role: "user", UserID: "user-api-key"}, nil
+	}

	if h.auth == nil {
		return nil, fmt.Errorf("authentication service not configured")
	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/middleware.go` around lines 220 - 239, The requireUser function
only handles admin API keys and bearer tokens but must also accept user API keys
like authenticate does; after calling extractAPIKey and checkAdminAPIKey, add
the same user-API-key branch authenticate uses (e.g., call the existing helper
used by authenticate such as h.checkUserAPIKey or h.auth.ValidateAPIKey) and if
it validates return a Session with Role "user" and the associated UserID, then
continue to the bearer token path; ensure this user-API-key check runs before
falling back to extractBearerToken/ValidateSession so valid user-key requests
are accepted.

Comment on lines +53 to +92
func TestRouter_AuthUser_RejectsInvalidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "stale-token").Return(nil, errors.New("session not found"))
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer stale-token"},
}
_, err := r.Route(ctx, "GET", "/api/test/user", req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 401, ce.code)
}

// TestRouter_AuthUser_AcceptsValidSession confirms the router-layer
// check passes through to the handler when a valid session is present.
// This is the positive case for the new requireUser helper.
func TestRouter_AuthUser_AcceptsValidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession(), nil)
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer user-token"},
}
result, err := r.Route(ctx, "GET", "/api/test/user", req)
require.NoError(t, err)
assert.Equal(t, map[string]string{"status": "ok"}, result)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Mock expectations are not asserted in the ValidateSession tests.

mockAuth.On(...) is configured, but AssertExpectations is never called. That can allow false positives if the call path changes.

Suggested fix
func TestRouter_AuthUser_RejectsInvalidSession(t *testing.T) {
	ctx := context.Background()
	mockAuth := new(MockAuthService)
+	t.Cleanup(func() { mockAuth.AssertExpectations(t) })
	mockAuth.On("ValidateSession", ctx, "stale-token").Return(nil, errors.New("session not found"))
	...
}

func TestRouter_AuthUser_AcceptsValidSession(t *testing.T) {
	ctx := context.Background()
	mockAuth := new(MockAuthService)
+	t.Cleanup(func() { mockAuth.AssertExpectations(t) })
	mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession(), nil)
	...
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestRouter_AuthUser_RejectsInvalidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "stale-token").Return(nil, errors.New("session not found"))
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer stale-token"},
}
_, err := r.Route(ctx, "GET", "/api/test/user", req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 401, ce.code)
}
// TestRouter_AuthUser_AcceptsValidSession confirms the router-layer
// check passes through to the handler when a valid session is present.
// This is the positive case for the new requireUser helper.
func TestRouter_AuthUser_AcceptsValidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession(), nil)
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer user-token"},
}
result, err := r.Route(ctx, "GET", "/api/test/user", req)
require.NoError(t, err)
assert.Equal(t, map[string]string{"status": "ok"}, result)
}
func TestRouter_AuthUser_RejectsInvalidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
mockAuth.On("ValidateSession", ctx, "stale-token").Return(nil, errors.New("session not found"))
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer stale-token"},
}
_, err := r.Route(ctx, "GET", "/api/test/user", req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 401, ce.code)
}
// TestRouter_AuthUser_AcceptsValidSession confirms the router-layer
// check passes through to the handler when a valid session is present.
// This is the positive case for the new requireUser helper.
func TestRouter_AuthUser_AcceptsValidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession(), nil)
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer user-token"},
}
result, err := r.Route(ctx, "GET", "/api/test/user", req)
require.NoError(t, err)
assert.Equal(t, map[string]string{"status": "ok"}, result)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/router_auth_test.go` around lines 53 - 92, In both
TestRouter_AuthUser_RejectsInvalidSession and
TestRouter_AuthUser_AcceptsValidSession, ensure the mock expectations on
MockAuth are asserted after exercising the Router: call
mockAuth.AssertExpectations(t) (or mockAuth.AssertExpectations) at the end of
each test so the ValidateSession expectation set with
mockAuth.On("ValidateSession", ...) is verified; this ensures MockAuth's
ValidateSession call actually occurred for the Handler/Router code paths.

Comment thread internal/api/router.go
Comment on lines +245 to 256
switch route.Auth {
case AuthAdmin:
if _, err := r.h.requireAdmin(ctx, req); err != nil {
return nil, err
}
case AuthUser:
if _, err := r.h.requireUser(ctx, req); err != nil {
return nil, err
}
case AuthPublic:
// no router-level auth — handler runs directly.
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Auth switch should fail closed on unknown auth levels.

The switch route.Auth has no default, so unsupported values currently execute handlers with no auth guard. For an auth boundary, this should deny by default.

Suggested fix
			switch route.Auth {
			case AuthAdmin:
				if _, err := r.h.requireAdmin(ctx, req); err != nil {
					return nil, err
				}
			case AuthUser:
				if _, err := r.h.requireUser(ctx, req); err != nil {
					return nil, err
				}
			case AuthPublic:
				// no router-level auth — handler runs directly.
+			default:
+				return nil, fmt.Errorf("unsupported auth level: %d", route.Auth)
			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
switch route.Auth {
case AuthAdmin:
if _, err := r.h.requireAdmin(ctx, req); err != nil {
return nil, err
}
case AuthUser:
if _, err := r.h.requireUser(ctx, req); err != nil {
return nil, err
}
case AuthPublic:
// no router-level auth — handler runs directly.
}
switch route.Auth {
case AuthAdmin:
if _, err := r.h.requireAdmin(ctx, req); err != nil {
return nil, err
}
case AuthUser:
if _, err := r.h.requireUser(ctx, req); err != nil {
return nil, err
}
case AuthPublic:
// no router-level auth — handler runs directly.
default:
return nil, fmt.Errorf("unsupported auth level: %d", route.Auth)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/router.go` around lines 245 - 256, The switch over route.Auth
(AuthAdmin/AuthUser/AuthPublic) currently lacks a default so unknown auth values
fall through and run the handler; update the switch in the router to include a
default branch that fails closed by returning an authorization error (e.g., a
descriptive error like "unsupported auth level" or a forbidden error) instead of
allowing execution, and ensure this default returns from the surrounding
function the same way r.h.requireAdmin/ r.h.requireUser errors are returned so
no handler runs for unknown auth values.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 29, 2026

Closing as superseded by #84 (fix(security): defence-in-depth AuthUser check in Router.Route), which was merged into feat/multicloud-web-frontend on 2026-04-28 and closes the same issue (#60).

Both PRs implement defence-in-depth router-layer enforcement of AuthUser, but #84 is the better fit:

Rebasing this branch onto current feat/multicloud-web-frontend produced unresolvable conflicts (4 in internal/api/router.go + middleware.go, all in the same regions both PRs touched): taking this branch's side reintroduces the user-API-key regression; taking base's side empties the diff.

The one ergonomic idea worth keeping from this PR — having the auth helper return the resolved principal so handlers don't re-fetch it — is filed as a follow-up at #194. That refactor will build on #84's requireAuth/authenticate() rather than reintroduce a parallel helper.

Thanks for the parallel attempt — closing without merge.

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

Labels

effort/s Hours impact/all-users Affects every user priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant