feat: enable SSO with email allowlist authorization
- Add config/allowed_users.yaml as the source of truth for access control (email → role mapping, case-insensitive) - New backend/app/services/allowlist.py loads the YAML and provides lookup() - auth.py checks the allowlist on every SSO login; denies with 403 if not listed; syncs AppUser.role from YAML on each login - PyYAML added to requirements.txt - docker-compose mounts ./config:/app/config into the backend container - Frontend: axios response interceptor catches 403 not_allowlisted and fires a custom DOM event; AuthProvider renders a NoAccessPage with Sign out button - .env.example: clarify DEV_AUTH_BYPASS usage, document ALLOWED_USERS_PATH Azure AD: add https://optical-dev.oliver.solutions/oliver-sales-ops-platform/ as a SPA redirect URI in app registration 9079054c (done separately by zlalani). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
bcc3200ebb
commit
c7025ee396
8 changed files with 183 additions and 12 deletions
|
|
@ -2,9 +2,13 @@ POSTGRES_PASSWORD=your_strong_password_here
|
|||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
AZURE_TENANT_ID=your-azure-tenant-id
|
||||
AZURE_CLIENT_ID=your-azure-client-id
|
||||
# Set to true to skip SSO in local dev (never use in production)
|
||||
# Set to true ONLY for local dev to skip SSO. NEVER set on optical-dev or any deployed env.
|
||||
# Leave blank (or false) to enable real Azure AD SSO.
|
||||
DEV_AUTH_BYPASS=
|
||||
VITE_DEV_AUTH_BYPASS=
|
||||
# Optional: override path to allowed_users.yaml. Default in Docker: /app/config/allowed_users.yaml
|
||||
# For local dev outside Docker: set to ./config/allowed_users.yaml
|
||||
ALLOWED_USERS_PATH=
|
||||
# Absolute path to the directory containing reference data (GMAL Excel etc.)
|
||||
# Defaults to ./data (relative to repo) if not set
|
||||
DATA_DIR=./data
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.database import get_db
|
||||
from app.models.user import AppUser, UserRole
|
||||
from app.services.allowlist import lookup as allowlist_lookup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -73,10 +74,10 @@ async def _decode_token(token: str) -> dict:
|
|||
async def _upsert_app_user(db: AsyncSession, identity: dict) -> AppUser:
|
||||
"""Find or create the AppUser for this identity. Stamps last_login on hit.
|
||||
|
||||
For users coming through the dev-bypass path, the role is read from the
|
||||
DEV_AUTH_ROLE env var so the dev server can hand out an admin login
|
||||
without SSO. SSO-authenticated users default to EDITOR (admins are
|
||||
promoted via the user-management UI).
|
||||
SSO path: role is read from allowed_users.yaml (via identity["allowed_role"])
|
||||
and synced on every login — the YAML is the source of truth.
|
||||
Dev-bypass path: role comes from DEV_AUTH_ROLE env var; can promote to admin
|
||||
but never demotes an existing admin row.
|
||||
"""
|
||||
email = (identity.get("email") or "").strip().lower()
|
||||
oid = identity.get("oid")
|
||||
|
|
@ -84,13 +85,15 @@ async def _upsert_app_user(db: AsyncSession, identity: dict) -> AppUser:
|
|||
# Best-effort: use oid as fallback synthetic email so we still get a row.
|
||||
email = f"{oid or 'unknown'}@unknown.local"
|
||||
|
||||
desired_role = UserRole.EDITOR
|
||||
if identity.get("dev_bypass"):
|
||||
role_str = (identity.get("role") or "editor").lower()
|
||||
try:
|
||||
desired_role = UserRole(role_str)
|
||||
except ValueError:
|
||||
desired_role = UserRole.EDITOR
|
||||
else:
|
||||
# SSO path: role comes from the allowlist (already validated in get_current_user).
|
||||
desired_role = identity["allowed_role"]
|
||||
|
||||
result = await db.execute(select(AppUser).where(AppUser.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
|
@ -111,11 +114,19 @@ async def _upsert_app_user(db: AsyncSession, identity: dict) -> AppUser:
|
|||
user.azure_oid = oid
|
||||
if identity.get("name") and not user.name:
|
||||
user.name = identity["name"]
|
||||
# If the dev-bypass identity wants a higher role than what's on the row
|
||||
# (e.g. dev server promoting to admin), upgrade it here. Don't downgrade.
|
||||
if identity.get("dev_bypass") and desired_role == UserRole.ADMIN and user.role != UserRole.ADMIN:
|
||||
user.role = UserRole.ADMIN
|
||||
logger.info("Promoted AppUser %s (%s) to admin via dev bypass", user.id, email)
|
||||
if identity.get("dev_bypass"):
|
||||
# Dev bypass can promote to admin but never demotes.
|
||||
if desired_role == UserRole.ADMIN and user.role != UserRole.ADMIN:
|
||||
user.role = UserRole.ADMIN
|
||||
logger.info("Promoted AppUser %s (%s) to admin via dev bypass", user.id, email)
|
||||
else:
|
||||
# Allowlist is source of truth — sync role on every SSO login.
|
||||
if user.role != desired_role:
|
||||
logger.info(
|
||||
"Role sync for AppUser %s (%s): %s → %s",
|
||||
user.id, email, user.role.value, desired_role.value,
|
||||
)
|
||||
user.role = desired_role
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
|
@ -144,6 +155,19 @@ async def get_current_user(
|
|||
except JWTError as e:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {e}")
|
||||
|
||||
email = (identity.get("email") or "").strip().lower()
|
||||
allowed_role = allowlist_lookup(email)
|
||||
if allowed_role is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"code": "not_allowlisted",
|
||||
"email": email,
|
||||
"message": "Your account is not authorized for this app.",
|
||||
},
|
||||
)
|
||||
identity["allowed_role"] = allowed_role
|
||||
|
||||
user = await _upsert_app_user(db, identity)
|
||||
await db.commit()
|
||||
return {
|
||||
|
|
|
|||
69
backend/app/services/allowlist.py
Normal file
69
backend/app/services/allowlist.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Email-based access control list loaded from config/allowed_users.yaml.
|
||||
|
||||
Loaded once at first use. The file path is configurable via ALLOWED_USERS_PATH
|
||||
(default: /app/config/allowed_users.yaml, which is the Docker volume mount).
|
||||
Fail-closed: if the file is missing or invalid, lookup() raises so the app
|
||||
refuses access rather than silently admitting everyone.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_allowlist: dict[str, UserRole] | None = None
|
||||
_load_error: Exception | None = None
|
||||
|
||||
|
||||
def _load() -> dict[str, UserRole]:
|
||||
path = os.environ.get(
|
||||
"ALLOWED_USERS_PATH",
|
||||
"/app/config/allowed_users.yaml",
|
||||
)
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
f"Allowlist file not found at {path}. "
|
||||
"Create config/allowed_users.yaml or set ALLOWED_USERS_PATH."
|
||||
)
|
||||
|
||||
result: dict[str, UserRole] = {}
|
||||
for entry in (data or {}).get("users", []):
|
||||
email = (entry.get("email") or "").strip().lower()
|
||||
role_str = (entry.get("role") or "editor").strip().lower()
|
||||
if not email:
|
||||
continue
|
||||
try:
|
||||
role = UserRole(role_str)
|
||||
except ValueError:
|
||||
logger.warning("Unknown role %r for %s — defaulting to editor", role_str, email)
|
||||
role = UserRole.EDITOR
|
||||
result[email] = role
|
||||
|
||||
logger.info("Allowlist loaded: %d user(s) from %s", len(result), path)
|
||||
return result
|
||||
|
||||
|
||||
def _get() -> dict[str, UserRole]:
|
||||
global _allowlist, _load_error
|
||||
if _load_error is not None:
|
||||
raise _load_error
|
||||
if _allowlist is None:
|
||||
try:
|
||||
_allowlist = _load()
|
||||
except Exception as exc:
|
||||
_load_error = exc
|
||||
raise
|
||||
return _allowlist
|
||||
|
||||
|
||||
def lookup(email: str) -> UserRole | None:
|
||||
"""Return the UserRole for *email* or None if not in the allowlist."""
|
||||
return _get().get(email.strip().lower())
|
||||
|
|
@ -15,6 +15,7 @@ pandas==2.2.3
|
|||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
PyYAML>=6.0
|
||||
httpx==0.28.1
|
||||
redis==5.2.1
|
||||
celery==5.4.0
|
||||
|
|
|
|||
11
config/allowed_users.yaml
Normal file
11
config/allowed_users.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# OLIVER Sales Ops Platform — access control list.
|
||||
# Email matching is case-insensitive. Roles: viewer | editor | admin
|
||||
# To add/remove users: edit this file, commit, and redeploy.
|
||||
users:
|
||||
- email: vadymsamoilenko@oliver.agency
|
||||
role: admin
|
||||
- email: zlalani@oliver.agency
|
||||
role: admin
|
||||
# Add remaining users from the approved list below (copy from the screenshot):
|
||||
# - email: someone@oliver.agency
|
||||
# role: editor
|
||||
|
|
@ -61,6 +61,7 @@ services:
|
|||
- ./backend/app:/app/app
|
||||
- ./backend/alembic:/app/alembic
|
||||
- ./backend/alembic.ini:/app/alembic.ini
|
||||
- ./config:/app/config
|
||||
|
||||
# Vite dev-server container — used for local development only.
|
||||
# In production (deploy server) we build the static SPA via deploy.sh
|
||||
|
|
|
|||
|
|
@ -23,4 +23,19 @@ api.interceptors.request.use(async (config) => {
|
|||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.detail?.code === 'not_allowlisted'
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('auth:not-allowlisted', { detail: error.response.data.detail }),
|
||||
);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MsalProvider, useMsal, AuthenticatedTemplate, UnauthenticatedTemplate } from '@azure/msal-react';
|
||||
import { msalInstance, loginRequest } from './msalConfig';
|
||||
|
||||
|
|
@ -31,9 +31,46 @@ function LoginPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function NoAccessPage({ email, onLogout }: { email: string; onLogout: () => void }) {
|
||||
return (
|
||||
<div style={loginPageStyle}>
|
||||
<div style={loginBoxStyle}>
|
||||
<div style={loginLogoStyle}>
|
||||
<span style={logoMarkStyle}>O</span>
|
||||
<span>Sales Ops Platform</span>
|
||||
</div>
|
||||
<p style={{ ...loginDescStyle, marginBottom: 8 }}>
|
||||
Your account is not authorized for this app.
|
||||
</p>
|
||||
<p style={{ ...loginDescStyle, fontFamily: 'monospace', fontSize: 12, marginBottom: 20 }}>
|
||||
{email}
|
||||
</p>
|
||||
<p style={{ ...loginDescStyle, marginBottom: 20 }}>
|
||||
Contact your admin to request access.
|
||||
</p>
|
||||
<button style={loginBtnStyle} onClick={onLogout}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RedirectHandler({ children }: { children: React.ReactNode }) {
|
||||
const { instance } = useMsal();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [blockedEmail, setBlockedEmail] = useState<string | null>(null);
|
||||
|
||||
const handleRef = useRef((e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setBlockedEmail(detail?.email ?? 'unknown');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = handleRef.current;
|
||||
window.addEventListener('auth:not-allowlisted', handler);
|
||||
return () => window.removeEventListener('auth:not-allowlisted', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
instance.handleRedirectPromise().then(() => setReady(true)).catch(() => setReady(true));
|
||||
|
|
@ -41,6 +78,15 @@ function RedirectHandler({ children }: { children: React.ReactNode }) {
|
|||
|
||||
if (!ready) return null;
|
||||
|
||||
if (blockedEmail !== null) {
|
||||
return (
|
||||
<NoAccessPage
|
||||
email={blockedEmail}
|
||||
onLogout={() => instance.logoutRedirect()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthenticatedTemplate>{children}</AuthenticatedTemplate>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue