From b51fa14680056f74494384825fb865f97e629b2c Mon Sep 17 00:00:00 2001 From: Luiz Carrossoni Date: Thu, 2 Apr 2026 11:03:52 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20RBAC=20=E2=80=94=20role-based=20acc?= =?UTF-8?q?ess=20control=20integrated=20with=20Databricks=20workspace=20id?= =?UTF-8?q?entity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #6: access control using Databricks workspace identity and Unity Catalog-style permission levels. | Role | Query | Configure gateways | Create/Delete gateways | Manage users | |--------|-------|--------------------|------------------------|--------------| | use | ✓ | | | | | manage | ✓ | ✓ | | | | owner | ✓ | ✓ | ✓ | ✓ | Workspace admins (Databricks 'admins' group) are always treated as owner. - `services/rbac.py`: role resolution — checks SCIM /Me for workspace admin, then user_roles table, then defaults to 'use' - `api/rbac_routes.py`: `/api/users/me`, `/api/users`, `/api/users/{email}/role` (POST/DELETE) — all owner-gated except /me - `storage_pgvector.py` + `storage_local.py` + `storage_dynamic.py` + `database.py`: user_roles table (pgvector) / dict (local) with get/set/list/delete CRUD - `gateway_routes.py`: POST /gateways → owner; PUT /gateways → manage; DELETE /gateways → owner; DELETE cache → manage; PUT /settings → owner - `main.py`: registers rbac_router at /api prefix - `context/RoleContext.jsx`: fetches /api/users/me on mount; provides { role, isOwner, isManage, loading } - `main.jsx`: wraps app in RoleProvider - `services/api.js`: getMyRole, listUsers, setUserRole, deleteUserRole - `GatewayListPage`: "New Gateway" button hidden for non-owners - `GatewayDetailPage`: "Delete gateway" hidden for non-owners; Settings tab hidden for 'use' role - `SettingsPage`: adds "Access Control → Users" sidebar section (owner only) with role matrix table, user list, add/remove user form Co-authored-by: Isaac --- backend/app/api/gateway_routes.py | 37 +++- backend/app/api/rbac_routes.py | 83 +++++++++ backend/app/main.py | 2 + backend/app/services/database.py | 14 ++ backend/app/services/rbac.py | 69 ++++++++ backend/app/services/storage_dynamic.py | 26 +++ backend/app/services/storage_local.py | 21 +++ backend/app/services/storage_pgvector.py | 69 ++++++++ .../components/gateways/GatewayDetailPage.jsx | 26 +-- .../components/gateways/GatewayListPage.jsx | 35 ++-- .../src/components/settings/SettingsPage.jsx | 167 +++++++++++++++++- frontend/src/context/RoleContext.jsx | 35 ++++ frontend/src/main.jsx | 5 +- frontend/src/services/api.js | 20 +++ 14 files changed, 571 insertions(+), 38 deletions(-) create mode 100644 backend/app/api/rbac_routes.py create mode 100644 backend/app/services/rbac.py create mode 100644 frontend/src/context/RoleContext.jsx diff --git a/backend/app/api/gateway_routes.py b/backend/app/api/gateway_routes.py index 9d297df..30f6250 100644 --- a/backend/app/api/gateway_routes.py +++ b/backend/app/api/gateway_routes.py @@ -15,6 +15,7 @@ from app.api.config_store import get_effective_setting, get_overrides, update_overrides from app.config import get_settings import app.services.database as _db +from app.services.rbac import resolve_role, role_gte logger = logging.getLogger(__name__) gateway_router = APIRouter() @@ -35,6 +36,19 @@ def _get_token(req: Request) -> str: return token +async def _require_role(req: Request, min_role: str): + """Resolve caller's effective role and raise 403 if below min_role.""" + token = _get_token(req) + identity = req.headers.get("X-Forwarded-Email", "") + host = _get_host() + role = await resolve_role(identity, token, host) + if not role_gte(role, min_role): + raise HTTPException( + status_code=403, + detail=f"Role '{min_role}' required. You have '{role}'." + ) + + def _get_host() -> str: """Get Databricks workspace host with https:// prefix.""" host = get_effective_setting("databricks_host") or settings.databricks_host @@ -69,7 +83,8 @@ async def list_gateways(): @gateway_router.post("/gateways", status_code=201) async def create_gateway(body: GatewayCreateRequest, req: Request): - """Create a new gateway configuration.""" + """Create a new gateway configuration. Owner only.""" + await _require_role(req, "owner") try: now = datetime.now(timezone.utc) user_email = req.headers.get("X-Forwarded-Email") @@ -132,8 +147,9 @@ async def get_gateway(gateway_id: str): @gateway_router.put("/gateways/{gateway_id}") -async def update_gateway(gateway_id: str, body: GatewayUpdateRequest): - """Update gateway fields.""" +async def update_gateway(gateway_id: str, body: GatewayUpdateRequest, req: Request): + """Update gateway fields. Manage or above.""" + await _require_role(req, "manage") try: updates = body.model_dump(exclude_none=True) if not updates: @@ -153,8 +169,9 @@ async def update_gateway(gateway_id: str, body: GatewayUpdateRequest): @gateway_router.delete("/gateways/{gateway_id}") -async def delete_gateway(gateway_id: str): - """Delete a gateway.""" +async def delete_gateway(gateway_id: str, req: Request): + """Delete a gateway. Owner only.""" + await _require_role(req, "owner") try: deleted = await _db.db_service.delete_gateway(gateway_id) if not deleted: @@ -218,8 +235,9 @@ async def get_gateway_cache(gateway_id: str): @gateway_router.delete("/gateways/{gateway_id}/cache") -async def clear_gateway_cache(gateway_id: str): - """Clear all cached entries for a specific gateway.""" +async def clear_gateway_cache(gateway_id: str, req: Request): + """Clear all cached entries for a specific gateway. Manage or above.""" + await _require_role(req, "manage") try: gw = await _db.db_service.get_gateway(gateway_id) if not gw: @@ -439,8 +457,9 @@ class SettingsUpdateRequest(GatewayUpdateRequest): @gateway_router.put("/settings") -async def update_settings_endpoint(body: SettingsUpdateRequest): - """Update server configuration.""" +async def update_settings_endpoint(body: SettingsUpdateRequest, req: Request): + """Update server configuration. Owner only.""" + await _require_role(req, "owner") batch = {} updated = {} for field, value in body.model_dump(exclude_none=True).items(): diff --git a/backend/app/api/rbac_routes.py b/backend/app/api/rbac_routes.py new file mode 100644 index 0000000..c62c94e --- /dev/null +++ b/backend/app/api/rbac_routes.py @@ -0,0 +1,83 @@ +"""RBAC management endpoints for user/role administration.""" + +import logging +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from app.api.auth_helpers import extract_bearer_token +from app.api.config_store import get_effective_setting +from app.config import get_settings +from app.services.rbac import resolve_role, role_gte, ROLES + +logger = logging.getLogger(__name__) +rbac_router = APIRouter() +_settings = get_settings() + + +def _get_host() -> str: + host = get_effective_setting("databricks_host") or _settings.databricks_host or "" + if host and not host.startswith("http"): + host = f"https://{host}" + return host + + +async def _resolve_caller(req: Request): + """Extract and resolve the calling user's identity and effective role.""" + token = extract_bearer_token(req) + identity = req.headers.get("X-Forwarded-Email", "") + role = await resolve_role(identity, token, _get_host()) + return identity, token, role + + +async def _require_role(req: Request, min_role: str): + identity, token, role = await _resolve_caller(req) + if not role_gte(role, min_role): + raise HTTPException( + status_code=403, + detail=f"Role '{min_role}' required. You have '{role}'." + ) + return identity, token, role + + +@rbac_router.get("/users/me") +async def get_my_role(req: Request): + """Return the current user's identity and effective role.""" + identity, _, role = await _resolve_caller(req) + return {"identity": identity, "role": role} + + +@rbac_router.get("/users") +async def list_users(req: Request): + """List all explicit role assignments. Owner only.""" + await _require_role(req, "owner") + import app.services.database as _db + return await _db.db_service.list_user_roles() + + +class RoleAssignment(BaseModel): + role: str + + +@rbac_router.post("/users/{email}/role", status_code=200) +async def assign_role(email: str, body: RoleAssignment, req: Request): + """Assign a role to a user. Owner only.""" + identity, _, _ = await _require_role(req, "owner") + if body.role not in ROLES: + raise HTTPException( + status_code=400, + detail=f"Invalid role '{body.role}'. Valid roles: {ROLES}" + ) + import app.services.database as _db + await _db.db_service.set_user_role(email, body.role, granted_by=identity) + logger.info("Role assigned: %s → %s by %s", email, body.role, identity) + return {"identity": email, "role": body.role} + + +@rbac_router.delete("/users/{email}") +async def remove_user_role(email: str, req: Request): + """Remove explicit role assignment (reverts to default 'use'). Owner only.""" + identity, _, _ = await _require_role(req, "owner") + import app.services.database as _db + await _db.db_service.delete_user_role(email) + logger.info("Role removed: %s by %s", email, identity) + return {"success": True} diff --git a/backend/app/main.py b/backend/app/main.py index 26cb091..b8d09c2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from app.api.genie_clone_routes import genie_clone_router from app.api.gateway_routes import gateway_router from app.api.mcp_routes import mcp_router +from app.api.rbac_routes import rbac_router from app.config import get_settings logger = logging.getLogger(__name__) @@ -78,6 +79,7 @@ async def _token_refresh_loop(): app.include_router(router, prefix="/api") app.include_router(gateway_router, prefix="/api") +app.include_router(rbac_router, prefix="/api") app.include_router(genie_clone_router, prefix="/api/2.0/genie") app.include_router(mcp_router, prefix="/api/2.0/mcp") diff --git a/backend/app/services/database.py b/backend/app/services/database.py index 8e6709b..3da97cd 100644 --- a/backend/app/services/database.py +++ b/backend/app/services/database.py @@ -157,3 +157,17 @@ async def delete_gateway(self, gateway_id: str) -> bool: async def get_gateway_stats(self, gateway_id: str) -> dict: return await self.backend.get_gateway_stats(gateway_id) + + # --- User roles --- + + async def get_user_role(self, identity: str): + return await self.backend.get_user_role(identity) + + async def set_user_role(self, identity: str, role: str, granted_by: str = None): + return await self.backend.set_user_role(identity, role, granted_by) + + async def list_user_roles(self) -> list: + return await self.backend.list_user_roles() + + async def delete_user_role(self, identity: str): + return await self.backend.delete_user_role(identity) diff --git a/backend/app/services/rbac.py b/backend/app/services/rbac.py new file mode 100644 index 0000000..1fefe25 --- /dev/null +++ b/backend/app/services/rbac.py @@ -0,0 +1,69 @@ +""" +Role-based access control for Genie Cache Gateway. + +Roles (lowest → highest privilege): + use — query only: submit questions, view results + manage — configure gateways, view/clear cache; cannot create/delete gateways or manage users + owner — full control: create/delete gateways, manage users, configure settings + +Workspace admins are always treated as owner regardless of the user_roles table. +Unassigned users default to 'use'. +""" + +import logging +import httpx + +logger = logging.getLogger(__name__) + +ROLES = ['use', 'manage', 'owner'] +ROLE_HIERARCHY = {'use': 1, 'manage': 2, 'owner': 3} +DEFAULT_ROLE = 'use' + + +def role_gte(a: str, b: str) -> bool: + """Return True if role a >= role b in the privilege hierarchy.""" + return ROLE_HIERARCHY.get(a, 0) >= ROLE_HIERARCHY.get(b, 0) + + +async def is_workspace_admin(token: str, host: str) -> bool: + """Check if the token owner is a Databricks workspace admin via SCIM /Me. + Workspace admins belong to the built-in 'admins' group. + """ + if not token or not host: + return False + if not host.startswith("http"): + host = f"https://{host}" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get( + f"{host}/api/2.0/preview/scim/v2/Me", + headers={"Authorization": f"Bearer {token}"} + ) + if resp.status_code == 200: + groups = resp.json().get("groups", []) + return any(g.get("display") == "admins" for g in groups) + except Exception as e: + logger.debug("Workspace admin check failed: %s", e) + return False + + +async def resolve_role(identity: str, token: str, host: str) -> str: + """ + Resolve the effective role for a user: + 1. Workspace admins → 'owner' (checked via Databricks SCIM API) + 2. Explicit assignment in user_roles table + 3. Default → 'use' + """ + import app.services.database as _db + + # Workspace admins always get owner, regardless of any local assignment + if await is_workspace_admin(token, host): + return 'owner' + + # Check explicit assignment in the database + if _db.db_service and identity: + assigned = await _db.db_service.get_user_role(identity) + if assigned: + return assigned + + return DEFAULT_ROLE diff --git a/backend/app/services/storage_dynamic.py b/backend/app/services/storage_dynamic.py index 02a97ba..1c2e6e5 100644 --- a/backend/app/services/storage_dynamic.py +++ b/backend/app/services/storage_dynamic.py @@ -342,3 +342,29 @@ async def get_gateway_stats(self, gateway_id: str) -> dict: if hasattr(backend, 'pool'): return await backend.get_gateway_stats(gateway_id) return backend.get_gateway_stats(gateway_id) + + # --- User roles CRUD (delegates to default backend) --- + + async def get_user_role(self, identity: str): + backend = self.default_backend + if hasattr(backend, 'pool'): + return await backend.get_user_role(identity) + return backend.get_user_role(identity) + + async def set_user_role(self, identity: str, role: str, granted_by: str = None): + backend = self.default_backend + if hasattr(backend, 'pool'): + return await backend.set_user_role(identity, role, granted_by) + return backend.set_user_role(identity, role, granted_by) + + async def list_user_roles(self) -> list: + backend = self.default_backend + if hasattr(backend, 'pool'): + return await backend.list_user_roles() + return backend.list_user_roles() + + async def delete_user_role(self, identity: str): + backend = self.default_backend + if hasattr(backend, 'pool'): + return await backend.delete_user_role(identity) + return backend.delete_user_role(identity) diff --git a/backend/app/services/storage_local.py b/backend/app/services/storage_local.py index 28fc26b..0a7fedf 100644 --- a/backend/app/services/storage_local.py +++ b/backend/app/services/storage_local.py @@ -30,6 +30,7 @@ def __init__(self, cache_file: str, embeddings_file: str, cache_ttl_hours: int = self.embeddings_file = embeddings_file self.cache_ttl_hours = cache_ttl_hours self._gateways: Dict = {} + self._user_roles: Dict = {} # identity -> {role, granted_by, granted_at} self._ensure_data_dir() self._load_data() @@ -218,6 +219,26 @@ def delete_gateway(self, gateway_id: str) -> bool: logger.info("Gateway deleted: id=%s", gateway_id) return True + # --- User roles CRUD --- + + def get_user_role(self, identity: str): + entry = self._user_roles.get(identity) + return entry["role"] if entry else None + + def set_user_role(self, identity: str, role: str, granted_by: str = None): + self._user_roles[identity] = { + "identity": identity, + "role": role, + "granted_by": granted_by, + "granted_at": datetime.now().isoformat() + "Z", + } + + def list_user_roles(self) -> list: + return sorted(self._user_roles.values(), key=lambda r: r.get("granted_at", ""), reverse=True) + + def delete_user_role(self, identity: str): + self._user_roles.pop(identity, None) + def get_gateway_stats(self, gateway_id: str) -> Dict: """Get cache and query stats for a gateway.""" gw = self._gateways.get(gateway_id) diff --git a/backend/app/services/storage_pgvector.py b/backend/app/services/storage_pgvector.py index ba07479..407a6c0 100644 --- a/backend/app/services/storage_pgvector.py +++ b/backend/app/services/storage_pgvector.py @@ -71,6 +71,7 @@ def __init__( # Gateway table in same schema as cache table schema_prefix = self.table_name.rsplit('.', 1)[0] if '.' in self.table_name else 'public' self.gateway_table_name = f"{schema_prefix}.gateway_configs" + self.user_roles_table_name = f"{schema_prefix}.user_roles" def _normalize_table_name(self, table_name: str) -> str: """Convert Databricks catalog.schema.table to PostgreSQL schema.table format.""" @@ -172,6 +173,7 @@ async def initialize(self): await self._ensure_table(conn) await self._ensure_query_log_table(conn) await self._ensure_gateway_table(conn) + await self._ensure_user_roles_table(conn) await self._migrate_genie_space_id_columns(conn) await self._migrate_original_query_text(conn) await self._migrate_caching_enabled(conn) @@ -459,6 +461,18 @@ async def _ensure_query_log_table(self, conn): logger.info("Query log table '%s' initialized", self.query_log_table_name) + async def _ensure_user_roles_table(self, conn): + """Create the user_roles table if it does not exist.""" + await conn.execute(f""" + CREATE TABLE IF NOT EXISTS {self.user_roles_table_name} ( + identity TEXT PRIMARY KEY, + role TEXT NOT NULL, + granted_by TEXT, + granted_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + logger.info("User roles table '%s' initialized", self.user_roles_table_name) + async def _ensure_gateway_table(self, conn): """Create the gateway_configs table if it does not exist.""" await conn.execute(f""" @@ -962,6 +976,61 @@ async def get_gateway_stats(self, gateway_id: str) -> dict: return {"cache_count": cache_count, "query_count_7d": query_count, "cache_hits_7d": cache_hits} + # --- User roles CRUD --- + + async def get_user_role(self, identity: str) -> Optional[str]: + """Return the explicit role for a user, or None if not assigned.""" + if not self.pool: + return None + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + f"SELECT role FROM {self.user_roles_table_name} WHERE identity = $1", + identity + ) + return row["role"] if row else None + + async def set_user_role(self, identity: str, role: str, granted_by: str = None): + """Insert or update a user's role assignment.""" + if not self.pool: + return + async with self.pool.acquire() as conn: + await conn.execute(f""" + INSERT INTO {self.user_roles_table_name} (identity, role, granted_by, granted_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (identity) DO UPDATE + SET role = EXCLUDED.role, + granted_by = EXCLUDED.granted_by, + granted_at = NOW() + """, identity, role, granted_by) + + async def list_user_roles(self) -> list: + """Return all explicit role assignments.""" + if not self.pool: + return [] + async with self.pool.acquire() as conn: + rows = await conn.fetch( + f"SELECT identity, role, granted_by, granted_at FROM {self.user_roles_table_name} ORDER BY granted_at DESC" + ) + return [ + { + "identity": r["identity"], + "role": r["role"], + "granted_by": r["granted_by"], + "granted_at": _to_utc_iso(r["granted_at"]), + } + for r in rows + ] + + async def delete_user_role(self, identity: str): + """Remove an explicit role assignment.""" + if not self.pool: + return + async with self.pool.acquire() as conn: + await conn.execute( + f"DELETE FROM {self.user_roles_table_name} WHERE identity = $1", + identity + ) + def _row_to_gateway_dict(self, row) -> dict: """Convert a database row to a gateway dict.""" return { diff --git a/frontend/src/components/gateways/GatewayDetailPage.jsx b/frontend/src/components/gateways/GatewayDetailPage.jsx index b011a16..3eb4cb1 100644 --- a/frontend/src/components/gateways/GatewayDetailPage.jsx +++ b/frontend/src/components/gateways/GatewayDetailPage.jsx @@ -2,23 +2,25 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import { Trash2, Play, Copy, Loader2 } from 'lucide-react' import { api } from '../../services/api' +import { useRole } from '../../context/RoleContext' import GatewayOverviewTab from './GatewayOverviewTab' import GatewayMetricsTab from './GatewayMetricsTab' import GatewayCacheTab from './GatewayCacheTab' import GatewayLogsTab from './GatewayLogsTab' import GatewaySettingsTab from './GatewaySettingsTab' -const TABS = [ +const ALL_TABS = [ { id: 'overview', label: 'Overview' }, { id: 'metrics', label: 'Metrics' }, { id: 'cache', label: 'Cache' }, { id: 'logs', label: 'Logs' }, - { id: 'settings', label: 'Settings' }, + { id: 'settings', label: 'Settings', minRole: 'manage' }, ] export default function GatewayDetailPage() { const { id } = useParams() const navigate = useNavigate() + const { isOwner, isManage } = useRole() const [gateway, setGateway] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -63,6 +65,8 @@ export default function GatewayDetailPage() { navigator.clipboard.writeText(text) } + const TABS = ALL_TABS.filter((t) => !t.minRole || isManage) + if (loading) { return (
@@ -125,14 +129,16 @@ export default function GatewayDetailPage() { Test in Playground - + {isOwner && ( + + )}
diff --git a/frontend/src/components/gateways/GatewayListPage.jsx b/frontend/src/components/gateways/GatewayListPage.jsx index a2af5ae..441d6f9 100644 --- a/frontend/src/components/gateways/GatewayListPage.jsx +++ b/frontend/src/components/gateways/GatewayListPage.jsx @@ -6,9 +6,11 @@ import DataTable from '../shared/DataTable' import StatusBadge from '../shared/StatusBadge' import EmptyState from '../shared/EmptyState' import GatewayCreateModal from './GatewayCreateModal' +import { useRole } from '../../context/RoleContext' export default function GatewayListPage() { const navigate = useNavigate() + const { isOwner } = useRole() const [gateways, setGateways] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -153,13 +155,15 @@ export default function GatewayListPage() { Intelligent caching gateway for Databricks Genie API

- + {isOwner && ( + + )} {/* Search bar */} @@ -198,7 +202,7 @@ export default function GatewayListPage() { icon={Layers} title="No gateways yet" description="Create a gateway to start caching Genie queries and accelerating your analytics." - action={ + action={isOwner ? ( - } + ) : null} /> ) : ( )} - {/* Create modal */} - setShowCreate(false)} - onCreated={handleCreated} - /> + {isOwner && ( + setShowCreate(false)} + onCreated={handleCreated} + /> + )} ) } diff --git a/frontend/src/components/settings/SettingsPage.jsx b/frontend/src/components/settings/SettingsPage.jsx index ee639d3..81d6214 100644 --- a/frontend/src/components/settings/SettingsPage.jsx +++ b/frontend/src/components/settings/SettingsPage.jsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Pencil, Eye, EyeOff, Loader2, CheckCircle, XCircle, FlaskConical, Database, SlidersHorizontal, Palette } from 'lucide-react' +import { Pencil, Eye, EyeOff, Loader2, CheckCircle, XCircle, FlaskConical, Database, SlidersHorizontal, Palette, Users, Trash2 } from 'lucide-react' import { api } from '../../services/api' import { useTheme } from '../../context/ThemeContext' +import { useRole } from '../../context/RoleContext' const secondsToTtl = (seconds) => { if (!seconds || seconds === 0) return { value: '0', unit: 'hours' } @@ -125,7 +126,7 @@ function EndpointSelect({ value, onChange, endpoints, loading, placeholder, filt } /* ── Sidebar structure ── */ -const SIDEBAR = [ +const SIDEBAR_BASE = [ { category: 'Preferences', icon: Palette, items: [{ id: 'appearance', label: 'Appearance' }] }, { category: 'Connection', icon: Database, items: [{ id: 'general', label: 'General' }] }, { category: 'Gateway Defaults', icon: SlidersHorizontal, items: [ @@ -134,11 +135,22 @@ const SIDEBAR = [ { id: 'ai-pipeline', label: 'AI Pipeline' }, ]}, ] +const SIDEBAR_OWNER = [ + { category: 'Access Control', icon: Users, items: [{ id: 'users', label: 'Users' }] }, +] /* ── Main component ── */ export default function SettingsPage() { const { themeMode, setThemeMode } = useTheme() - const [activeSection, setActiveSection] = useState('general') + const { isOwner } = useRole() + const [activeSection, setActiveSection] = useState('appearance') + + // Users management state (owner only) + const [users, setUsers] = useState([]) + const [usersLoading, setUsersLoading] = useState(false) + const [newUserEmail, setNewUserEmail] = useState('') + const [newUserRole, setNewUserRole] = useState('use') + const [userSaving, setUserSaving] = useState(false) const [config, setConfig] = useState({ storage_backend: 'lakebase', lakebase_service_token: '', lakebase_instance_name: '', lakebase_catalog: 'default', lakebase_schema: 'public', @@ -198,6 +210,39 @@ export default function SettingsPage() { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } }, []) + // Load users when owner navigates to users section + useEffect(() => { + if (isOwner && activeSection === 'users' && users.length === 0) { + setUsersLoading(true) + api.listUsers() + .then(setUsers) + .catch(() => setUsers([])) + .finally(() => setUsersLoading(false)) + } + }, [isOwner, activeSection]) + + const handleAddUser = async () => { + if (!newUserEmail.trim()) return + setUserSaving(true) + try { + await api.setUserRole(newUserEmail.trim(), newUserRole) + const updated = await api.listUsers() + setUsers(updated) + setNewUserEmail('') + setNewUserRole('use') + } catch { /* ignore */ } + finally { setUserSaving(false) } + } + + const handleRemoveUser = async (email) => { + try { + await api.deleteUserRole(email) + setUsers(prev => prev.filter(u => u.identity !== email)) + } catch { /* ignore */ } + } + + const SIDEBAR = isOwner ? [...SIDEBAR_BASE, ...SIDEBAR_OWNER] : SIDEBAR_BASE + const persistSettings = useCallback(async () => { const c = configRef.current setSaveStatus('saving') @@ -460,6 +505,122 @@ export default function SettingsPage() { + {/* ── Users (owner only) ── */} + {isOwner && ( +
sectionRefs.current['users'] = el} className="mb-10"> +

Users

+ +
+ Workspace admins always have Owner access regardless of this list. +
+ + {/* Role matrix */} +
+ + + + + + + + + + + + {[ + { role: 'use', q: true, c: false, cd: false, mu: false }, + { role: 'manage', q: true, c: true, cd: false, mu: false }, + { role: 'owner', q: true, c: true, cd: true, mu: true }, + ].map(({ role, q, c, cd, mu }) => ( + + + {[q, c, cd, mu].map((v, i) => ( + + ))} + + ))} + +
RoleQueryConfigureCreate/DeleteManage Users
{role} + {v ? '●' : '○'} +
+
+ + {/* User list */} + {usersLoading ? ( +
+ Loading users... +
+ ) : ( +
+ {users.length === 0 ? ( +
+ No explicit role assignments. Add users below. +
+ ) : ( + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
UserRoleGranted by +
{u.identity}{u.role}{u.granted_by || '—'} + +
+ )} +
+ )} + + {/* Add user */} +
+ setNewUserEmail(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddUser()} + className={`${inputClass} flex-1`} + style={{ maxWidth: '260px' }} + /> + + +
+
+ )} + {/* ── AI Pipeline ── */}
sectionRefs.current['ai-pipeline'] = el} className="mb-10">

AI Pipeline

diff --git a/frontend/src/context/RoleContext.jsx b/frontend/src/context/RoleContext.jsx new file mode 100644 index 0000000..e49efb2 --- /dev/null +++ b/frontend/src/context/RoleContext.jsx @@ -0,0 +1,35 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { api } from '../services/api' + +// role: 'owner' | 'manage' | 'use' +// isOwner: role === 'owner' +// isManage: role === 'manage' || role === 'owner' + +const RoleContext = createContext({ role: 'use', isOwner: false, isManage: false, loading: true }) + +export function RoleProvider({ children }) { + const [role, setRole] = useState('use') + const [loading, setLoading] = useState(true) + + useEffect(() => { + api.getMyRole() + .then((data) => setRole(data.role || 'use')) + .catch(() => setRole('use')) + .finally(() => setLoading(false)) + }, []) + + return ( + + {children} + + ) +} + +export function useRole() { + return useContext(RoleContext) +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index ea6ce77..67a2a64 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,13 +3,16 @@ import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { ThemeProvider } from './context/ThemeContext.jsx' +import { RoleProvider } from './context/RoleContext.jsx' import './index.css' ReactDOM.createRoot(document.getElementById('root')).render( - + + + , diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index a76079c..66d33b4 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -259,6 +259,26 @@ export const api = { return response.data; }, + getMyRole: async () => { + const response = await axios.get(`${API_BASE_URL}/users/me`); + return response.data; // { identity, role } + }, + + listUsers: async () => { + const response = await axios.get(`${API_BASE_URL}/users`); + return response.data; // [{ identity, role, granted_by, granted_at }] + }, + + setUserRole: async (email, role) => { + const response = await axios.post(`${API_BASE_URL}/users/${encodeURIComponent(email)}/role`, { role }); + return response.data; + }, + + deleteUserRole: async (email) => { + const response = await axios.delete(`${API_BASE_URL}/users/${encodeURIComponent(email)}`); + return response.data; + }, + getWorkspaceAppearance: async () => { try { const response = await axios.get(`${API_BASE_URL}/workspace-appearance`); From f36b79fdcf3a9f9b3f6ffd1299ffb25b3840c3cc Mon Sep 17 00:00:00 2001 From: Luiz Carrossoni Date: Thu, 2 Apr 2026 11:12:49 -0300 Subject: [PATCH 2/4] fix: allow manage role to also manage users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per updated spec: manage can list, assign, and remove user roles. Previously only owner could do so. Updated role matrix: use → query only manage → configure gateways, clear cache, manage users owner → full control (create/delete gateways + everything manage can do) Co-authored-by: Isaac --- backend/app/api/rbac_routes.py | 12 ++++++------ .../src/components/settings/SettingsPage.jsx | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/app/api/rbac_routes.py b/backend/app/api/rbac_routes.py index c62c94e..e5b05be 100644 --- a/backend/app/api/rbac_routes.py +++ b/backend/app/api/rbac_routes.py @@ -48,8 +48,8 @@ async def get_my_role(req: Request): @rbac_router.get("/users") async def list_users(req: Request): - """List all explicit role assignments. Owner only.""" - await _require_role(req, "owner") + """List all explicit role assignments. Manage or above.""" + await _require_role(req, "manage") import app.services.database as _db return await _db.db_service.list_user_roles() @@ -60,8 +60,8 @@ class RoleAssignment(BaseModel): @rbac_router.post("/users/{email}/role", status_code=200) async def assign_role(email: str, body: RoleAssignment, req: Request): - """Assign a role to a user. Owner only.""" - identity, _, _ = await _require_role(req, "owner") + """Assign a role to a user. Manage or above.""" + identity, _, _ = await _require_role(req, "manage") if body.role not in ROLES: raise HTTPException( status_code=400, @@ -75,8 +75,8 @@ async def assign_role(email: str, body: RoleAssignment, req: Request): @rbac_router.delete("/users/{email}") async def remove_user_role(email: str, req: Request): - """Remove explicit role assignment (reverts to default 'use'). Owner only.""" - identity, _, _ = await _require_role(req, "owner") + """Remove explicit role assignment (reverts to default 'use'). Manage or above.""" + identity, _, _ = await _require_role(req, "manage") import app.services.database as _db await _db.db_service.delete_user_role(email) logger.info("Role removed: %s by %s", email, identity) diff --git a/frontend/src/components/settings/SettingsPage.jsx b/frontend/src/components/settings/SettingsPage.jsx index 81d6214..94e5db3 100644 --- a/frontend/src/components/settings/SettingsPage.jsx +++ b/frontend/src/components/settings/SettingsPage.jsx @@ -135,14 +135,14 @@ const SIDEBAR_BASE = [ { id: 'ai-pipeline', label: 'AI Pipeline' }, ]}, ] -const SIDEBAR_OWNER = [ +const SIDEBAR_MANAGE = [ { category: 'Access Control', icon: Users, items: [{ id: 'users', label: 'Users' }] }, ] /* ── Main component ── */ export default function SettingsPage() { const { themeMode, setThemeMode } = useTheme() - const { isOwner } = useRole() + const { isOwner, isManage } = useRole() const [activeSection, setActiveSection] = useState('appearance') // Users management state (owner only) @@ -210,9 +210,9 @@ export default function SettingsPage() { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } }, []) - // Load users when owner navigates to users section + // Load users when manage/owner navigates to users section useEffect(() => { - if (isOwner && activeSection === 'users' && users.length === 0) { + if (isManage && activeSection === 'users' && users.length === 0) { setUsersLoading(true) api.listUsers() .then(setUsers) @@ -241,7 +241,7 @@ export default function SettingsPage() { } catch { /* ignore */ } } - const SIDEBAR = isOwner ? [...SIDEBAR_BASE, ...SIDEBAR_OWNER] : SIDEBAR_BASE + const SIDEBAR = isManage ? [...SIDEBAR_BASE, ...SIDEBAR_MANAGE] : SIDEBAR_BASE const persistSettings = useCallback(async () => { const c = configRef.current @@ -505,8 +505,8 @@ export default function SettingsPage() {
- {/* ── Users (owner only) ── */} - {isOwner && ( + {/* ── Users (manage+) ── */} + {isManage && (
sectionRefs.current['users'] = el} className="mb-10">

Users

@@ -529,7 +529,7 @@ export default function SettingsPage() { {[ { role: 'use', q: true, c: false, cd: false, mu: false }, - { role: 'manage', q: true, c: true, cd: false, mu: false }, + { role: 'manage', q: true, c: true, cd: false, mu: true }, { role: 'owner', q: true, c: true, cd: true, mu: true }, ].map(({ role, q, c, cd, mu }) => ( From 7f280a36f2a9d030734636690aae2cbe1b29dd8d Mon Sep 17 00:00:00 2001 From: Luiz Carrossoni Date: Thu, 2 Apr 2026 11:21:06 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20rbac=20code=20review=20=E2=80=94=20c?= =?UTF-8?q?aching,=20auth=20model,=20bugs,=20useMemo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance: - rbac.py: cache is_workspace_admin result (60s TTL, keyed on token) and get_user_role result (120s TTL, keyed on identity) to avoid SCIM+DB I/O on every protected request; shared module-level httpx.AsyncClient eliminates per-call TCP handshake - rbac_routes.py / gateway_routes.py: call invalidate_role_cache after every set_user_role / delete_user_role write so changes take effect immediately Auth model: - gateway_routes._require_role: switch from _get_token() (which has a service-token fallback) to extract_bearer_token() — using the SP token for authorization checks would grant the SP's role to unauthenticated callers Code reuse: - rbac.py / rbac_routes.py / gateway_routes.py: replace inline `if not host.startswith("http")` with ensure_https() from app.auth, matching the pattern used in 4 other route files Bug: - SettingsPage users useEffect: dep array had [isOwner, ...] but condition checked isManage — manage-role users would never see the users list load; fixed to [isManage, activeSection]; added usersLoadedRef to avoid re-fetching after removing the last user UX / efficiency: - handleAddUser: use response from setUserRole for optimistic local upsert instead of a second listUsers round-trip - RoleContext: useMemo on context value to avoid re-rendering all consumers on unrelated parent state changes - SettingsPage: useMemo on SIDEBAR (stable once role resolves) Co-authored-by: Isaac --- backend/app/api/gateway_routes.py | 12 +-- backend/app/api/rbac_routes.py | 9 ++- backend/app/services/rbac.py | 79 ++++++++++++++----- .../src/components/settings/SettingsPage.jsx | 26 +++--- frontend/src/context/RoleContext.jsx | 16 ++-- 5 files changed, 96 insertions(+), 46 deletions(-) diff --git a/backend/app/api/gateway_routes.py b/backend/app/api/gateway_routes.py index 30f6250..be097e6 100644 --- a/backend/app/api/gateway_routes.py +++ b/backend/app/api/gateway_routes.py @@ -11,7 +11,9 @@ import httpx from fastapi import APIRouter, HTTPException, Request +from app.auth import ensure_https from app.models import GatewayConfig, GatewayCreateRequest, GatewayUpdateRequest +from app.api.auth_helpers import extract_bearer_token from app.api.config_store import get_effective_setting, get_overrides, update_overrides from app.config import get_settings import app.services.database as _db @@ -37,8 +39,10 @@ def _get_token(req: Request) -> str: async def _require_role(req: Request, min_role: str): - """Resolve caller's effective role and raise 403 if below min_role.""" - token = _get_token(req) + """Resolve caller's effective role and raise 403 if below min_role. + Uses extract_bearer_token (user OBO token only — no service-token fallback). + """ + token = extract_bearer_token(req) identity = req.headers.get("X-Forwarded-Email", "") host = _get_host() role = await resolve_role(identity, token, host) @@ -54,9 +58,7 @@ def _get_host() -> str: host = get_effective_setting("databricks_host") or settings.databricks_host if not host: raise HTTPException(status_code=500, detail="DATABRICKS_HOST not configured") - if not host.startswith("http"): - host = f"https://{host}" - return host + return ensure_https(host) # --- Gateway CRUD --- diff --git a/backend/app/api/rbac_routes.py b/backend/app/api/rbac_routes.py index e5b05be..20b321d 100644 --- a/backend/app/api/rbac_routes.py +++ b/backend/app/api/rbac_routes.py @@ -4,10 +4,11 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel +from app.auth import ensure_https from app.api.auth_helpers import extract_bearer_token from app.api.config_store import get_effective_setting from app.config import get_settings -from app.services.rbac import resolve_role, role_gte, ROLES +from app.services.rbac import resolve_role, role_gte, ROLES, invalidate_role_cache logger = logging.getLogger(__name__) rbac_router = APIRouter() @@ -16,9 +17,7 @@ def _get_host() -> str: host = get_effective_setting("databricks_host") or _settings.databricks_host or "" - if host and not host.startswith("http"): - host = f"https://{host}" - return host + return ensure_https(host) if host else host async def _resolve_caller(req: Request): @@ -69,6 +68,7 @@ async def assign_role(email: str, body: RoleAssignment, req: Request): ) import app.services.database as _db await _db.db_service.set_user_role(email, body.role, granted_by=identity) + invalidate_role_cache(email) logger.info("Role assigned: %s → %s by %s", email, body.role, identity) return {"identity": email, "role": body.role} @@ -79,5 +79,6 @@ async def remove_user_role(email: str, req: Request): identity, _, _ = await _require_role(req, "manage") import app.services.database as _db await _db.db_service.delete_user_role(email) + invalidate_role_cache(email) logger.info("Role removed: %s by %s", email, identity) return {"success": True} diff --git a/backend/app/services/rbac.py b/backend/app/services/rbac.py index 1fefe25..68c9894 100644 --- a/backend/app/services/rbac.py +++ b/backend/app/services/rbac.py @@ -3,67 +3,104 @@ Roles (lowest → highest privilege): use — query only: submit questions, view results - manage — configure gateways, view/clear cache; cannot create/delete gateways or manage users - owner — full control: create/delete gateways, manage users, configure settings + manage — configure gateways, view/clear cache, manage users + owner — full control: create/delete gateways, configure settings Workspace admins are always treated as owner regardless of the user_roles table. Unassigned users default to 'use'. """ import logging +import time + import httpx +from app.auth import ensure_https + logger = logging.getLogger(__name__) ROLES = ['use', 'manage', 'owner'] ROLE_HIERARCHY = {'use': 1, 'manage': 2, 'owner': 3} DEFAULT_ROLE = 'use' +# Shared HTTP client — avoids per-call TCP+TLS handshake overhead +_http_client = httpx.AsyncClient(timeout=5.0) + +# Short-lived in-process caches to avoid hammering SCIM and DB on every request. +# Keys: token (admin check) and identity (role lookup). TTLs are conservative — +# role changes take effect within the TTL window without a restart. +_ADMIN_CACHE_TTL = 60.0 # seconds +_ROLE_CACHE_TTL = 120.0 # seconds +_admin_cache: dict[str, tuple[bool, float]] = {} # token → (is_admin, expires_at) +_role_cache: dict[str, tuple[str, float]] = {} # identity → (role, expires_at) + def role_gte(a: str, b: str) -> bool: """Return True if role a >= role b in the privilege hierarchy.""" return ROLE_HIERARCHY.get(a, 0) >= ROLE_HIERARCHY.get(b, 0) +def invalidate_role_cache(identity: str) -> None: + """Evict a cached role so the next request re-reads from the database. + Call this immediately after any set_user_role / delete_user_role write. + """ + _role_cache.pop(identity, None) + + async def is_workspace_admin(token: str, host: str) -> bool: """Check if the token owner is a Databricks workspace admin via SCIM /Me. - Workspace admins belong to the built-in 'admins' group. + Result is cached for _ADMIN_CACHE_TTL seconds to avoid per-request SCIM calls. """ if not token or not host: return False - if not host.startswith("http"): - host = f"https://{host}" + host = ensure_https(host) + + now = time.monotonic() + cached = _admin_cache.get(token) + if cached is not None: + result, expires_at = cached + if now < expires_at: + return result + + result = False try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get( - f"{host}/api/2.0/preview/scim/v2/Me", - headers={"Authorization": f"Bearer {token}"} - ) - if resp.status_code == 200: - groups = resp.json().get("groups", []) - return any(g.get("display") == "admins" for g in groups) + resp = await _http_client.get( + f"{host}/api/2.0/preview/scim/v2/Me", + headers={"Authorization": f"Bearer {token}"} + ) + if resp.status_code == 200: + groups = resp.json().get("groups", []) + result = any(g.get("display") == "admins" for g in groups) except Exception as e: logger.debug("Workspace admin check failed: %s", e) - return False + + _admin_cache[token] = (result, now + _ADMIN_CACHE_TTL) + return result async def resolve_role(identity: str, token: str, host: str) -> str: """ Resolve the effective role for a user: - 1. Workspace admins → 'owner' (checked via Databricks SCIM API) - 2. Explicit assignment in user_roles table + 1. Workspace admins → 'owner' (checked via Databricks SCIM API, cached 60 s) + 2. Explicit assignment in user_roles table (cached 120 s, invalidated on write) 3. Default → 'use' """ import app.services.database as _db - # Workspace admins always get owner, regardless of any local assignment if await is_workspace_admin(token, host): return 'owner' - # Check explicit assignment in the database + now = time.monotonic() + cached = _role_cache.get(identity) + if cached is not None: + role, expires_at = cached + if now < expires_at: + return role + + assigned = None if _db.db_service and identity: assigned = await _db.db_service.get_user_role(identity) - if assigned: - return assigned - return DEFAULT_ROLE + role = assigned or DEFAULT_ROLE + _role_cache[identity] = (role, now + _ROLE_CACHE_TTL) + return role diff --git a/frontend/src/components/settings/SettingsPage.jsx b/frontend/src/components/settings/SettingsPage.jsx index 94e5db3..b0f0a59 100644 --- a/frontend/src/components/settings/SettingsPage.jsx +++ b/frontend/src/components/settings/SettingsPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Pencil, Eye, EyeOff, Loader2, CheckCircle, XCircle, FlaskConical, Database, SlidersHorizontal, Palette, Users, Trash2 } from 'lucide-react' import { api } from '../../services/api' import { useTheme } from '../../context/ThemeContext' @@ -145,12 +145,13 @@ export default function SettingsPage() { const { isOwner, isManage } = useRole() const [activeSection, setActiveSection] = useState('appearance') - // Users management state (owner only) + // Users management state (manage role and above) const [users, setUsers] = useState([]) const [usersLoading, setUsersLoading] = useState(false) const [newUserEmail, setNewUserEmail] = useState('') const [newUserRole, setNewUserRole] = useState('use') const [userSaving, setUserSaving] = useState(false) + const usersLoadedRef = useRef(false) const [config, setConfig] = useState({ storage_backend: 'lakebase', lakebase_service_token: '', lakebase_instance_name: '', lakebase_catalog: 'default', lakebase_schema: 'public', @@ -210,24 +211,28 @@ export default function SettingsPage() { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } }, []) - // Load users when manage/owner navigates to users section + // Load users once when manage/owner navigates to the users section useEffect(() => { - if (isManage && activeSection === 'users' && users.length === 0) { + if (isManage && activeSection === 'users' && !usersLoadedRef.current) { + usersLoadedRef.current = true setUsersLoading(true) api.listUsers() .then(setUsers) .catch(() => setUsers([])) .finally(() => setUsersLoading(false)) } - }, [isOwner, activeSection]) + }, [isManage, activeSection]) const handleAddUser = async () => { if (!newUserEmail.trim()) return setUserSaving(true) try { - await api.setUserRole(newUserEmail.trim(), newUserRole) - const updated = await api.listUsers() - setUsers(updated) + const saved = await api.setUserRole(newUserEmail.trim(), newUserRole) + // Optimistic update — upsert locally using the response, no second round-trip + setUsers(prev => { + const without = prev.filter(u => u.identity !== saved.identity) + return [...without, saved] + }) setNewUserEmail('') setNewUserRole('use') } catch { /* ignore */ } @@ -241,7 +246,10 @@ export default function SettingsPage() { } catch { /* ignore */ } } - const SIDEBAR = isManage ? [...SIDEBAR_BASE, ...SIDEBAR_MANAGE] : SIDEBAR_BASE + const SIDEBAR = useMemo( + () => (isManage ? [...SIDEBAR_BASE, ...SIDEBAR_MANAGE] : SIDEBAR_BASE), + [isManage] + ) const persistSettings = useCallback(async () => { const c = configRef.current diff --git a/frontend/src/context/RoleContext.jsx b/frontend/src/context/RoleContext.jsx index e49efb2..a354e73 100644 --- a/frontend/src/context/RoleContext.jsx +++ b/frontend/src/context/RoleContext.jsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState } from 'react' +import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { api } from '../services/api' // role: 'owner' | 'manage' | 'use' @@ -18,13 +18,15 @@ export function RoleProvider({ children }) { .finally(() => setLoading(false)) }, []) + const value = useMemo(() => ({ + role, + loading, + isOwner: role === 'owner', + isManage: role === 'manage' || role === 'owner', + }), [role, loading]) + return ( - + {children} ) From 0052fb9bee829ba51cea1fec7b8e4d18b1c41112 Mon Sep 17 00:00:00 2001 From: Luiz Carrossoni Date: Thu, 2 Apr 2026 13:07:10 -0300 Subject: [PATCH 4/4] fix: remove SP token fallback from gateway_routes entirely Delete _get_token() and replace all call sites with extract_bearer_token(), which requires a real user token (X-Forwarded-Access-Token or Authorization Bearer) and raises 401 immediately if absent. No SP/service token fallback anywhere in the request path. Co-authored-by: Isaac --- backend/app/api/gateway_routes.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/backend/app/api/gateway_routes.py b/backend/app/api/gateway_routes.py index be097e6..6e83bbf 100644 --- a/backend/app/api/gateway_routes.py +++ b/backend/app/api/gateway_routes.py @@ -24,19 +24,6 @@ settings = get_settings() -def _get_token(req: Request) -> str: - """Extract bearer token from request headers.""" - token = req.headers.get("X-Forwarded-Access-Token") - if not token: - auth = req.headers.get("Authorization", "") - if auth.startswith("Bearer "): - token = auth[7:] - if not token: - token = get_effective_setting("lakebase_service_token") or settings.databricks_token - if not token: - raise HTTPException(status_code=401, detail="No authentication token available") - return token - async def _require_role(req: Request, min_role: str): """Resolve caller's effective role and raise 403 if below min_role. @@ -276,7 +263,7 @@ async def get_gateway_logs(gateway_id: str, limit: int = 50): async def list_genie_spaces(req: Request): """List available Genie Spaces from the workspace.""" try: - token = _get_token(req) + token = extract_bearer_token(req) host = _get_host() url = f"{host}/api/2.0/genie/spaces" @@ -300,7 +287,7 @@ async def list_genie_spaces(req: Request): async def list_warehouses(req: Request): """List available SQL warehouses from the workspace.""" try: - token = _get_token(req) + token = extract_bearer_token(req) host = _get_host() url = f"{host}/api/2.0/sql/warehouses" @@ -324,7 +311,7 @@ async def list_warehouses(req: Request): async def list_serving_endpoints(req: Request): """List available serving endpoints from the workspace.""" try: - token = _get_token(req) + token = extract_bearer_token(req) host = _get_host() url = f"{host}/api/2.0/serving-endpoints"