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:
Vadym Samoilenko 2026-04-28 19:27:39 +01:00
parent bcc3200ebb
commit c7025ee396
8 changed files with 183 additions and 12 deletions

View file

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

View file

@ -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 {

View 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())

View file

@ -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
View 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

View file

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

View file

@ -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;

View file

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