Multi-user via PocketID: account linking, group gating, admin user management
PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the data layer was already fully user-scoped. This adds the missing pieces for running real multi-user: - auth.py callback: link by email to an existing un-linked account (so the admin keeps their data when first signing in by passkey), collision-safe username generation, and request the `groups` scope. - Group gating: optional pocketid_allowed_group (admin-config or POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the callback and redirected to /login?auth_error=not_authorized. - New admin users API (app/api/users.py): list users, promote/demote admin (guards against demoting/locking out the last admin or yourself), and delete a user with ordered bulk deletes of all their data + on-disk files. - ProfilePage: allowed-group field; LoginPage: rejected-login message; Layout: admin-only Users nav; new UsersPage. Resync milevault_export to current source (it had drifted many features behind — missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own CI) and add POCKETID_ALLOWED_GROUP to .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+51
-3
@@ -24,6 +24,27 @@ async def _get_pocketid_config(db: AsyncSession):
|
||||
return issuer, client_id, client_secret
|
||||
|
||||
|
||||
async def _get_allowed_group(db: AsyncSession):
|
||||
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
|
||||
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
|
||||
admin = result.scalar_one_or_none()
|
||||
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
|
||||
return (group or "").strip() or None
|
||||
|
||||
|
||||
async def _unique_username(db: AsyncSession, base: str) -> str:
|
||||
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
|
||||
base = (base or "user").strip() or "user"
|
||||
candidate = base
|
||||
n = 1
|
||||
while True:
|
||||
existing = await db.execute(select(User).where(User.username == candidate))
|
||||
if existing.scalar_one_or_none() is None:
|
||||
return candidate
|
||||
n += 1
|
||||
candidate = f"{base}-{n}"
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
@@ -79,7 +100,7 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||
"client_id": client_id,
|
||||
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email",
|
||||
"scope": "openid profile email groups",
|
||||
}
|
||||
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||
|
||||
@@ -106,17 +127,44 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
userinfo = userinfo_resp.json()
|
||||
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
sub = userinfo.get("sub")
|
||||
email = userinfo.get("email")
|
||||
preferred_username = userinfo.get("preferred_username") or email
|
||||
|
||||
# Group gating: if an allowed group is configured, the user must be in it.
|
||||
allowed_group = await _get_allowed_group(db)
|
||||
if allowed_group:
|
||||
groups = userinfo.get("groups") or []
|
||||
if allowed_group not in groups:
|
||||
return RedirectResponse(url="/login?auth_error=not_authorized")
|
||||
|
||||
# 1) Existing passkey identity → use it.
|
||||
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 2) No passkey identity yet, but an account with this email exists and is
|
||||
# not already linked to a different passkey → link them (preserves data).
|
||||
if not user and email:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing and existing.pocketid_sub is None:
|
||||
existing.pocketid_sub = sub
|
||||
user = existing
|
||||
|
||||
# 3) Otherwise provision a new account with a collision-safe username.
|
||||
if not user:
|
||||
user = User(username=preferred_username, email=email, pocketid_sub=sub)
|
||||
base = preferred_username or (email.split("@")[0] if email else "user")
|
||||
username = await _unique_username(db, base)
|
||||
# Only set email if no other account already claims it (unique column).
|
||||
email_taken = False
|
||||
if email:
|
||||
dup = await db.execute(select(User).where(User.email == email))
|
||||
email_taken = dup.scalar_one_or_none() is not None
|
||||
user = User(username=username, email=None if email_taken else email, pocketid_sub=sub)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"/?token={token}")
|
||||
|
||||
@@ -121,6 +121,7 @@ class PocketIDConfig(BaseModel):
|
||||
issuer: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_secret: Optional[str] = None
|
||||
allowed_group: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/pocketid-config")
|
||||
@@ -131,10 +132,12 @@ async def get_pocketid_config(current_user: User = Depends(get_current_user)):
|
||||
# Show DB config if set, fall back to env
|
||||
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
|
||||
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
|
||||
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
|
||||
return {
|
||||
"issuer": issuer or "",
|
||||
"client_id": client_id or "",
|
||||
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
|
||||
"allowed_group": allowed_group or "",
|
||||
"enabled": bool(issuer and client_id),
|
||||
}
|
||||
|
||||
@@ -153,6 +156,8 @@ async def save_pocketid_config(
|
||||
current_user.pocketid_client_id = body.client_id or None
|
||||
if body.client_secret is not None:
|
||||
current_user.pocketid_client_secret = body.client_secret or None
|
||||
if body.allowed_group is not None:
|
||||
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Admin-only user management: list provisioned users, promote/demote admin,
|
||||
and delete a user together with all of their data.
|
||||
|
||||
New users are normally provisioned just-in-time on first PocketID login
|
||||
(see app/api/auth.py). This router is the in-app surface for managing them.
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import (
|
||||
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
||||
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_admin(current_user: User):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
|
||||
async def _admin_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
is_admin: bool
|
||||
has_passkey: bool
|
||||
activity_count: int
|
||||
created_at: Optional[str]
|
||||
|
||||
|
||||
class AdminUpdate(BaseModel):
|
||||
is_admin: bool
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
# activity counts per user in one grouped query
|
||||
counts = dict(
|
||||
(await db.execute(
|
||||
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
|
||||
)).all()
|
||||
)
|
||||
result = await db.execute(select(User).order_by(User.id))
|
||||
users = result.scalars().all()
|
||||
return [
|
||||
UserOut(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
email=u.email,
|
||||
is_admin=u.is_admin,
|
||||
has_passkey=u.pocketid_sub is not None,
|
||||
activity_count=counts.get(u.id, 0),
|
||||
created_at=u.created_at.isoformat() if u.created_at else None,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def set_admin(
|
||||
user_id: int,
|
||||
body: AdminUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "You cannot change your own admin status")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
# Demoting the last remaining admin would lock everyone out.
|
||||
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
|
||||
raise HTTPException(400, "Cannot demote the last admin")
|
||||
|
||||
user.is_admin = body.is_admin
|
||||
await db.commit()
|
||||
return {"status": "ok", "is_admin": user.is_admin}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_admin(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "You cannot delete your own account")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
if user.is_admin and await _admin_count(db) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the last admin")
|
||||
|
||||
# Ordered deletes: PersonalRecord and the activity/route child tables have no
|
||||
# cascade path from User, so remove them before the parents to avoid FK errors.
|
||||
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
|
||||
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id)
|
||||
|
||||
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
|
||||
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
|
||||
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
|
||||
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
|
||||
await db.execute(delete(Activity).where(Activity.user_id == user_id))
|
||||
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
|
||||
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
|
||||
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
|
||||
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
# Remove the user's uploaded files from disk (best-effort).
|
||||
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
|
||||
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
||||
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
|
||||
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
|
||||
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
|
||||
# Files
|
||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||
# Environment
|
||||
|
||||
+11
-1
@@ -6,7 +6,7 @@ import asyncio
|
||||
|
||||
from app.core.database import engine, AsyncSessionLocal, Base
|
||||
from app.core.config import settings
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
|
||||
|
||||
|
||||
async def init_db():
|
||||
@@ -73,6 +73,15 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"users.biological_sex column migration skipped: {e}")
|
||||
|
||||
# pocketid_allowed_group column on users added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pocketid_allowed_group VARCHAR(128)"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"users.pocketid_allowed_group column migration skipped: {e}")
|
||||
|
||||
# route_segments auto_generated column added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -215,6 +224,7 @@ app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -33,6 +33,8 @@ class User(Base):
|
||||
pocketid_issuer = Column(String(512), nullable=True)
|
||||
pocketid_client_id = Column(String(256), nullable=True)
|
||||
pocketid_client_secret = Column(String(256), nullable=True)
|
||||
# Only PocketID users in this group may sign in. Null/blank = allow all.
|
||||
pocketid_allowed_group = Column(String(128), nullable=True)
|
||||
|
||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
Reference in New Issue
Block a user