diff --git a/backend/app/api/gateway_routes.py b/backend/app/api/gateway_routes.py
index 9d297df..6e83bbf 100644
--- a/backend/app/api/gateway_routes.py
+++ b/backend/app/api/gateway_routes.py
@@ -11,28 +11,33 @@
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
+from app.services.rbac import resolve_role, role_gte
logger = logging.getLogger(__name__)
gateway_router = APIRouter()
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.
+ 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)
+ 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:
@@ -40,9 +45,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 ---
@@ -69,7 +72,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 +136,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 +158,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 +224,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:
@@ -256,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"
@@ -280,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"
@@ -304,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"
@@ -439,8 +446,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..20b321d
--- /dev/null
+++ b/backend/app/api/rbac_routes.py
@@ -0,0 +1,84 @@
+"""RBAC management endpoints for user/role administration."""
+
+import logging
+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, invalidate_role_cache
+
+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 ""
+ return ensure_https(host) if host else 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. Manage or above."""
+ await _require_role(req, "manage")
+ 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. Manage or above."""
+ identity, _, _ = await _require_role(req, "manage")
+ 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)
+ invalidate_role_cache(email)
+ 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'). Manage or above."""
+ 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/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..68c9894
--- /dev/null
+++ b/backend/app/services/rbac.py
@@ -0,0 +1,106 @@
+"""
+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, 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.
+ Result is cached for _ADMIN_CACHE_TTL seconds to avoid per-request SCIM calls.
+ """
+ if not token or not host:
+ return False
+ 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:
+ 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)
+
+ _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, 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
+
+ if await is_workspace_admin(token, host):
+ return 'owner'
+
+ 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)
+
+ role = assigned or DEFAULT_ROLE
+ _role_cache[identity] = (role, now + _ROLE_CACHE_TTL)
+ return 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..b0f0a59 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 { 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'
+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,23 @@ const SIDEBAR = [
{ id: 'ai-pipeline', label: 'AI Pipeline' },
]},
]
+const SIDEBAR_MANAGE = [
+ { 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, isManage } = useRole()
+ const [activeSection, setActiveSection] = useState('appearance')
+
+ // 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',
@@ -198,6 +211,46 @@ export default function SettingsPage() {
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [])
+ // Load users once when manage/owner navigates to the users section
+ useEffect(() => {
+ if (isManage && activeSection === 'users' && !usersLoadedRef.current) {
+ usersLoadedRef.current = true
+ setUsersLoading(true)
+ api.listUsers()
+ .then(setUsers)
+ .catch(() => setUsers([]))
+ .finally(() => setUsersLoading(false))
+ }
+ }, [isManage, activeSection])
+
+ const handleAddUser = async () => {
+ if (!newUserEmail.trim()) return
+ setUserSaving(true)
+ try {
+ 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 */ }
+ finally { setUserSaving(false) }
+ }
+
+ const handleRemoveUser = async (email) => {
+ try {
+ await api.deleteUserRole(email)
+ setUsers(prev => prev.filter(u => u.identity !== email))
+ } catch { /* ignore */ }
+ }
+
+ const SIDEBAR = useMemo(
+ () => (isManage ? [...SIDEBAR_BASE, ...SIDEBAR_MANAGE] : SIDEBAR_BASE),
+ [isManage]
+ )
+
const persistSettings = useCallback(async () => {
const c = configRef.current
setSaveStatus('saving')
@@ -460,6 +513,122 @@ export default function SettingsPage() {
+ {/* ── Users (manage+) ── */}
+ {isManage && (
+ sectionRefs.current['users'] = el} className="mb-10">
+
Users
+
+
+ Workspace admins always have Owner access regardless of this list.
+
+
+ {/* Role matrix */}
+
+
+
+
+ | Role |
+ Query |
+ Configure |
+ Create/Delete |
+ Manage Users |
+
+
+
+ {[
+ { role: 'use', q: true, c: false, 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 }) => (
+
+ | {role} |
+ {[q, c, cd, mu].map((v, i) => (
+
+ {v ? '●' : '○'}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ {/* User list */}
+ {usersLoading ? (
+
+ Loading users...
+
+ ) : (
+
+ {users.length === 0 ? (
+
+ No explicit role assignments. Add users below.
+
+ ) : (
+
+
+
+ | User |
+ Role |
+ Granted by |
+ |
+
+
+
+ {users.map((u) => (
+
+ | {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..a354e73
--- /dev/null
+++ b/frontend/src/context/RoleContext.jsx
@@ -0,0 +1,37 @@
+import { createContext, useContext, useEffect, useMemo, 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))
+ }, [])
+
+ const value = useMemo(() => ({
+ role,
+ loading,
+ isOwner: role === 'owner',
+ isManage: role === 'manage' || role === 'owner',
+ }), [role, loading])
+
+ 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`);