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:
2026-06-08 13:19:55 +01:00
parent bc4d68da07
commit 0e4bc7b444
46 changed files with 3282 additions and 588 deletions
+51 -3
View File
@@ -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}")
+5
View File
@@ -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"}
+142
View File
@@ -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"}
+1
View File
@@ -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
View File
@@ -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")
+2
View File
@@ -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")