Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c496187
refactor(auth): move the permission seeding builders to permission_se…
mrveiss Sep 11, 2026
816ae02
feat(auth): add chat, teams and webhooks permissions, and define ever…
mrveiss Sep 11, 2026
6d8a801
feat(auth): return effective permissions and is_admin from GET /me (#…
mrveiss Sep 11, 2026
c94b9f9
refactor(auth): give scope matching one implementation, shared by has…
mrveiss Sep 11, 2026
e0b1dc6
feat(auth): enforce API-key scopes as the owner role intersected with…
mrveiss Sep 11, 2026
083f9e8
chore(types): regenerate generated API types from OpenAPI schema(s)
github-actions[bot] Sep 11, 2026
2b256c0
Merge remote-tracking branch 'origin/Dev_new_gui' into issue-16270-pe…
mrveiss Sep 11, 2026
d630882
chore(ssot): drop the auth.py "user" baseline entry that /me took wit…
mrveiss Sep 11, 2026
bccc1c4
fix(infra): stop logging each seeded permission name, the sink of Cod…
mrveiss Sep 11, 2026
adda1a5
Merge remote-tracking branch 'origin/issue-16270-permission-scopes' i…
mrveiss Sep 11, 2026
219fd8f
fix(auth): use the Role enum for the login role claim, so auth.py car…
mrveiss Sep 11, 2026
f91c3e3
fix(auth): read the API-key grace period through env_int, and registe…
mrveiss Sep 11, 2026
20f673c
fix(lint): no-local-schemas scans API endpoint files, not their tests…
mrveiss Sep 11, 2026
d4435c3
test: reach get_current_user_info through api.auth_me, where #16270 m…
mrveiss Sep 11, 2026
c7ff13a
Merge remote-tracking branch 'origin/Dev_new_gui' into issue-16270-pe…
mrveiss Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions autobot-backend/api/api_endpoint_migrations_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18758,9 +18758,9 @@ def test_batch_111_logout_mixed_pattern(self):

def test_batch_111_get_current_user_info_mixed_pattern(self):
"""Verify get_current_user_info endpoint uses Mixed Pattern"""
from api import auth
from api import auth_me

source = inspect.getsource(auth.get_current_user_info)
source = inspect.getsource(auth_me.get_current_user_info)
# Should have @with_error_handling decorator
self.assertIn("@with_error_handling", source)
# Should have category parameter
Expand Down Expand Up @@ -18832,13 +18832,13 @@ def test_batch_111_change_password_simple_pattern(self):

def test_batch_111_all_auth_endpoints_have_decorator(self):
"""Verify all auth endpoints have @with_error_handling decorator"""
from api import auth
from api import auth, auth_me

# List of all endpoint functions in auth.py
endpoint_functions = [
auth.login,
auth.logout,
auth.get_current_user_info,
auth_me.get_current_user_info,
auth.check_authentication,
auth.check_permission,
auth.change_password,
Expand All @@ -18854,13 +18854,13 @@ def test_batch_111_all_auth_endpoints_have_decorator(self):

def test_batch_111_auth_100_percent_milestone(self):
"""Verify auth.py has reached 100% migration"""
from api import auth
from api import auth, auth_me

# List of all endpoint functions
endpoint_functions = [
auth.login,
auth.logout,
auth.get_current_user_info,
auth_me.get_current_user_info,
auth.check_authentication,
auth.check_permission,
auth.change_password,
Expand All @@ -18883,7 +18883,7 @@ def test_batch_111_auth_100_percent_milestone(self):

def test_batch_111_migration_preserves_authentication_logic(self):
"""Verify migration preserves authentication logic"""
from api import auth
from api import auth, auth_me

# Check login preserves authentication flow
source_login = inspect.getsource(auth.login)
Expand All @@ -18893,7 +18893,7 @@ def test_batch_111_migration_preserves_authentication_logic(self):
self.assertIn("LoginResponse", source_login)

# Check get_current_user_info preserves user data retrieval
source_me = inspect.getsource(auth.get_current_user_info)
source_me = inspect.getsource(auth_me.get_current_user_info)
self.assertIn("get_user_from_request", source_me)
self.assertIn("username", source_me)
self.assertIn("role", source_me)
Expand Down
46 changes: 5 additions & 41 deletions autobot-backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@

from fastapi import APIRouter, Depends, HTTPException, Request

from api.auth_me import router as me_router
from api.schemas_agent import (
AuthCheckResponse,
AuthPermissionResponse,
AuthRoleEntry,
AuthUserInfoResponse,
AuthValidateClaims,
AuthValidateRequest,
AuthValidateResponse,
Expand Down Expand Up @@ -133,7 +133,7 @@ async def _authenticate_and_build_user_data(username: str, password: str, ip_add
user_data = {
"username": user.username,
"user_id": str(user.id),
"role": "admin" if user.is_platform_admin else "user",
"role": role_value(Role.ADMIN) if user.is_platform_admin else role_value(Role.USER),
"email": user.email,
"last_login": (user.last_login_at.isoformat() if user.last_login_at else None),
}
Expand Down Expand Up @@ -297,45 +297,9 @@ async def revoke_rs256_token(
return {"revoked": result, "message": "Token revoked" if result else "Token already expired"}


@router.get("/me", response_model=AuthUserInfoResponse)
@with_error_handling(
category=ErrorCategory.SERVER_ERROR,
operation="get_current_user_info",
error_code_prefix="AUTH",
)
async def get_current_user_info(request: Request):
"""
Get current authenticated user information.
"""
try:
from user_management.config import get_deployment_config

config = get_deployment_config()
user_data = get_auth_middleware().get_user_from_request(request)

if not user_data:
raise HTTPException(status_code=401, detail="Not authenticated")

# #12135: use .get() with safe fallbacks, not hard `[...]` access.
# Every _extract_user_from_* path is expected to populate
# "username"/"role", but a valid, already-authenticated request
# must never 500 on an unexpected claim shape — degrade gracefully
# instead (matches the sub/user_id/username fallback convention
# used elsewhere, e.g. api/documents.py, api/voice.py).
return {
"username": user_data.get("username") or user_data.get("sub") or user_data.get("user_id", "unknown"),
"role": user_data.get("role", "user"),
"email": user_data.get("email", ""),
"auth_method": user_data.get("auth_method", "unknown"),
"authenticated": True,
"deployment_mode": config.mode.value,
}

except HTTPException:
raise
except Exception as e:
logger.error("Error getting user info: %s", e)
raise HTTPException(status_code=500, detail="Error retrieving user information")
# GET /me lives in api/auth_me.py: it returns effective permissions now (#16270)
# and this file is at its size ceiling. Included here, so the path is unchanged.
router.include_router(me_router)


@router.get("/check", response_model=AuthCheckResponse)
Expand Down
89 changes: 89 additions & 0 deletions autobot-backend/api/auth_me.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""
``GET /api/auth/me``: who the caller is, and what they may do (#16270).

Split out of ``api/auth.py``, which is frozen at its file-size ceiling. This
follows the ``password_change.py`` precedent (#15743). ``auth.py`` includes
this router, so the path is unchanged.

#16270 adds ``permissions`` and ``is_admin`` so the frontend can gate on the
server's answer instead of on its own copy of the role map (#16243). Both are
required. Superadmin holds no granular permissions by design (#13854), so a
frontend that gated on ``permissions`` alone would hide everything from a
superadmin. ``is_admin`` carries the other kind of server check.
"""

from typing import List

from fastapi import APIRouter, HTTPException, Request

from api.schemas_agent import AuthUserInfoResponse
from auth_middleware import get_auth_middleware
from autobot_shared.auth.permissions import Permission, Role, is_admin_role, role_has_permission
from autobot_shared.error_boundaries import ErrorCategory, with_error_handling
from autobot_shared.logging_manager import get_logger

router = APIRouter()
logger = get_logger(__name__)


class AuthMeResponse(AuthUserInfoResponse):
"""Response for GET /auth/me: the identity fields, plus the caller's effective authority (#16270)."""

permissions: List[str]
is_admin: bool


def effective_permissions(role: str) -> List[str]:
"""Return every ``Permission`` value *role* holds, as ``role_has_permission`` answers it.

That is the function the permission gates consult, so ``/me`` cannot
report a grant the gates would refuse, or miss one they would allow.
This does not keep a second copy of the rule.
"""
return sorted(p.value for p in Permission if role_has_permission(role, p.value))


@router.get("/me", response_model=AuthMeResponse)
@with_error_handling(
category=ErrorCategory.SERVER_ERROR,
operation="get_current_user_info",
error_code_prefix="AUTH",
)
async def get_current_user_info(request: Request):
"""Get the current authenticated user, with their effective permissions (#16270)."""
try:
from user_management.config import get_deployment_config

config = get_deployment_config()
user_data = get_auth_middleware().get_user_from_request(request)

if not user_data:
raise HTTPException(status_code=401, detail="Not authenticated")

# #12135: use .get() with safe fallbacks, not hard `[...]` access.
# Every _extract_user_from_* path is expected to populate
# "username"/"role". But a valid, already-authenticated request must
# never 500 on an unexpected claim shape, so degrade gracefully instead.
# This matches the sub/user_id/username fallback convention used
# elsewhere, e.g. api/documents.py and api/voice.py.
role = user_data.get("role", Role.USER.value)
return {
"username": user_data.get("username") or user_data.get("sub") or user_data.get("user_id", "unknown"),
"role": role,
"email": user_data.get("email", ""),
"auth_method": user_data.get("auth_method", "unknown"),
"authenticated": True,
"deployment_mode": config.mode.value,
"permissions": effective_permissions(role),
"is_admin": is_admin_role(role),
}

except HTTPException:
raise
except Exception as e:
logger.error("Error getting user info: %s", e)
raise HTTPException(status_code=500, detail="Error retrieving user information")
70 changes: 70 additions & 0 deletions autobot-backend/api/auth_me_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""``GET /api/auth/me`` reports the caller's effective permissions and admin status (#16270 AC5)."""

from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from api import auth_me
from autobot_shared.auth.permissions import ROLE_PERMISSIONS, Permission, Role


def _me(user_data):
"""Call /me as a caller whose auth middleware resolves to *user_data*."""
app = FastAPI()
app.include_router(auth_me.router, prefix="/api/auth")
middleware = MagicMock()
middleware.get_user_from_request.return_value = user_data
config = SimpleNamespace(mode=SimpleNamespace(value="single_company"))
with (
patch.object(auth_me, "get_auth_middleware", return_value=middleware),
patch("user_management.config.get_deployment_config", return_value=config),
):
return TestClient(app, raise_server_exceptions=False).get("/api/auth/me")


def test_a_superadmin_gets_no_granular_permissions_and_is_admin():
"""The AC5 case. A frontend gating on ``permissions`` alone would hide everything from this caller."""
body = _me({"username": "root", "role": "superadmin"}).json()
assert body["permissions"] == []
assert body["is_admin"] is True


def test_an_admin_gets_every_permission():
body = _me({"username": "a", "role": "admin"}).json()
assert body["permissions"] == sorted(p.value for p in Permission)
assert body["is_admin"] is True


@pytest.mark.parametrize("role", [Role.USER, Role.READONLY, Role.OPERATOR], ids=lambda r: r.value)
def test_a_non_admin_gets_exactly_its_role_permissions(role):
body = _me({"username": "u", "role": role.value}).json()
assert body["permissions"] == sorted(p.value for p in ROLE_PERMISSIONS[role])
assert body["is_admin"] is False


def test_an_unknown_role_gets_nothing():
"""It fails closed, the way ``role_has_permission`` does."""
body = _me({"username": "x", "role": "not-a-role"}).json()
assert body["permissions"] == []
assert body["is_admin"] is False


def test_the_identity_fields_are_unchanged():
body = _me({"username": "u", "role": "user", "email": "u@example.test", "auth_method": "jwt"}).json()
assert body["username"] == "u"
assert body["role"] == "user"
assert body["email"] == "u@example.test"
assert body["auth_method"] == "jwt"
assert body["authenticated"] is True
assert body["deployment_mode"] == "single_company"


def test_no_session_is_a_401():
assert _me(None).status_code == 401
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

- ``AuthenticationMiddleware._extract_user_from_jwt`` (auth_middleware.py):
``token_data["username"]`` / ``token_data["role"]``.
- ``get_current_user_info`` (api/auth.py): ``user_data["username"]`` /
- ``get_current_user_info`` (api/auth_me.py since #16270): ``user_data["username"]`` /
``user_data["role"]``.

Both must degrade gracefully (fallback to ``sub``/``user_id``, or a safe
Expand Down Expand Up @@ -89,11 +89,11 @@ def test_full_claims_token_still_works(self, real_auth_middleware):


class TestGetCurrentUserInfoMissingUsername:
"""GET /api/auth/me handler (api/auth.py) — defensive claim access (#12135)."""
"""GET /api/auth/me handler (api/auth_me.py since #16270) — defensive claim access (#12135)."""

@pytest.fixture
def auth_module(self):
import api.auth as auth_module
import api.auth_me as auth_module

return auth_module

Expand Down
48 changes: 26 additions & 22 deletions autobot-frontend/src/types/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ export interface paths {
};
/**
* Get Current User Info
* @description Get current authenticated user information.
* @description Get the current authenticated user, with their effective permissions (#16270).
*/
get: operations["get_current_user_info_api_auth_me_get"];
put?: never;
Expand Down Expand Up @@ -58612,6 +58612,30 @@ export interface components {
} & {
[key: string]: unknown;
};
/**
* AuthMeResponse
* @description Response for GET /auth/me: the identity fields, plus the caller's effective authority (#16270).
*/
AuthMeResponse: {
/** Username */
username: string;
/** Role */
role: string;
/** Email */
email: string;
/** Auth Method */
auth_method: string;
/** Authenticated */
authenticated: boolean;
/** Deployment Mode */
deployment_mode: string;
/** Permissions */
permissions: string[];
/** Is Admin */
is_admin: boolean;
} & {
[key: string]: unknown;
};
/**
* AuthPermissionResponse
* @description Response for GET /auth/permissions/{operation}.
Expand Down Expand Up @@ -58665,26 +58689,6 @@ export interface components {
} & {
[key: string]: unknown;
};
/**
* AuthUserInfoResponse
* @description Response for GET /auth/me.
*/
AuthUserInfoResponse: {
/** Username */
username: string;
/** Role */
role: string;
/** Email */
email: string;
/** Auth Method */
auth_method: string;
/** Authenticated */
authenticated: boolean;
/** Deployment Mode */
deployment_mode: string;
} & {
[key: string]: unknown;
};
/**
* AuthValidateClaims
* @description Claims block nested inside AuthValidateResponse.
Expand Down Expand Up @@ -104541,7 +104545,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AuthUserInfoResponse"];
"application/json": components["schemas"]["AuthMeResponse"];
};
};
};
Expand Down
Loading
Loading