diff --git a/app/api/admin.py b/app/api/admin.py index 5e5b81e..c01fc64 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -72,6 +72,7 @@ def grant_permission( user = users_repo.upsert_by_sub( authentik_sub=payload.authentik_sub, + username=None, email=payload.email, display_name=payload.display_name, is_active=True, diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index 3d32ec7..8823057 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -21,6 +21,7 @@ from app.schemas.catalog import ( MemberItem, MemberPermissionGroupsResponse, MemberPermissionGroupsUpdateRequest, + MemberPasswordResetResponse, MemberUpdateRequest, MemberUpsertRequest, ModuleCreateRequest, @@ -100,7 +101,9 @@ def _generate_unique_key(prefix: str, exists_fn) -> str: def _sync_member_to_authentik( *, - authentik_sub: str, + authentik_sub: str | None, + authentik_user_id: int | None, + username: str | None, email: str | None, display_name: str | None, is_active: bool, @@ -112,8 +115,10 @@ def _sync_member_to_authentik( result = service.ensure_user( sub=authentik_sub, email=email, + username=username, display_name=display_name, is_active=is_active, + authentik_user_id=authentik_user_id, ) return { "authentik_user_id": result.user_id, @@ -450,7 +455,22 @@ def list_members( ) -> dict: users_repo = UsersRepository(db) items, total = users_repo.list(keyword=keyword, limit=limit, offset=offset) - return {"items": [MemberItem(id=i.id, authentik_sub=i.authentik_sub, email=i.email, display_name=i.display_name, is_active=i.is_active).model_dump() for i in items], "total": total, "limit": limit, "offset": offset} + return { + "items": [ + MemberItem( + id=i.id, + authentik_sub=i.authentik_sub, + username=i.username, + email=i.email, + display_name=i.display_name, + is_active=i.is_active, + ).model_dump() + for i in items + ], + "total": total, + "limit": limit, + "offset": offset, + } @router.post("/members/upsert", response_model=MemberItem) @@ -460,13 +480,16 @@ def upsert_member( ) -> MemberItem: users_repo = UsersRepository(db) resolved_sub = payload.authentik_sub + resolved_username = payload.username authentik_user_id = None if payload.sync_to_authentik: - seed_sub = payload.authentik_sub or (payload.email or "") + seed_sub = payload.authentik_sub or payload.username if not seed_sub: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="authentik_sub_or_email_required") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="authentik_sub_or_username_required") sync = _sync_member_to_authentik( authentik_sub=seed_sub, + authentik_user_id=authentik_user_id, + username=payload.username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, @@ -478,6 +501,7 @@ def upsert_member( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="authentik_sub_required") row = users_repo.upsert_by_sub( authentik_sub=resolved_sub, + username=resolved_username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, @@ -486,6 +510,7 @@ def upsert_member( return MemberItem( id=row.id, authentik_sub=row.authentik_sub, + username=row.username, email=row.email, display_name=row.display_name, is_active=row.is_active, @@ -504,6 +529,7 @@ def update_member( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") next_email = payload.email if payload.email is not None else row.email + next_username = payload.username if payload.username is not None else row.username next_display_name = payload.display_name if payload.display_name is not None else row.display_name next_is_active = payload.is_active if payload.is_active is not None else row.is_active @@ -511,6 +537,8 @@ def update_member( if payload.sync_to_authentik: sync = _sync_member_to_authentik( authentik_sub=row.authentik_sub, + authentik_user_id=row.authentik_user_id, + username=next_username, email=next_email, display_name=next_display_name, is_active=next_is_active, @@ -519,6 +547,7 @@ def update_member( row = users_repo.upsert_by_sub( authentik_sub=row.authentik_sub, + username=next_username, email=next_email, display_name=next_display_name, is_active=next_is_active, @@ -527,12 +556,40 @@ def update_member( return MemberItem( id=row.id, authentik_sub=row.authentik_sub, + username=row.username, email=row.email, display_name=row.display_name, is_active=row.is_active, ) +@router.post("/members/{authentik_sub}/password/reset", response_model=MemberPasswordResetResponse) +def reset_member_password( + authentik_sub: str, + db: Session = Depends(get_db), +) -> MemberPasswordResetResponse: + users_repo = UsersRepository(db) + user = users_repo.get_by_sub(authentik_sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + settings = get_settings() + service = AuthentikAdminService(settings=settings) + result = service.reset_password( + authentik_user_id=user.authentik_user_id, + email=user.email, + username=user.username, + ) + user = users_repo.upsert_by_sub( + authentik_sub=user.authentik_sub, + username=user.username, + email=user.email, + display_name=user.display_name, + is_active=user.is_active, + authentik_user_id=result.user_id, + ) + return MemberPasswordResetResponse(authentik_sub=user.authentik_sub, temporary_password=result.temporary_password) + + @router.get("/members/{authentik_sub}/permission-groups", response_model=MemberPermissionGroupsResponse) def get_member_permission_groups( authentik_sub: str, diff --git a/app/api/internal.py b/app/api/internal.py index 8e65066..411f67e 100644 --- a/app/api/internal.py +++ b/app/api/internal.py @@ -31,6 +31,7 @@ def upsert_user_by_sub( repo = UsersRepository(db) user = repo.upsert_by_sub( authentik_sub=payload.sub, + username=payload.username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, @@ -39,6 +40,7 @@ def upsert_user_by_sub( "id": user.id, "sub": user.authentik_sub, "authentik_user_id": user.authentik_user_id, + "username": user.username, "email": user.email, "display_name": user.display_name, "is_active": user.is_active, @@ -73,13 +75,20 @@ def ensure_authentik_user( sync_result = authentik_service.ensure_user( sub=payload.sub, email=payload.email, + username=payload.username, display_name=payload.display_name, is_active=payload.is_active, ) users_repo = UsersRepository(db) + resolved_sub = payload.sub or "" + if sync_result.authentik_sub: + resolved_sub = sync_result.authentik_sub + if not resolved_sub: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_sub") users_repo.upsert_by_sub( - authentik_sub=payload.sub, + authentik_sub=resolved_sub, + username=payload.username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, diff --git a/app/api/internal_catalog.py b/app/api/internal_catalog.py index e8b4dd5..6c05269 100644 --- a/app/api/internal_catalog.py +++ b/app/api/internal_catalog.py @@ -94,4 +94,19 @@ def internal_list_members( ) -> dict: repo = UsersRepository(db) items, total = repo.list(keyword=keyword, limit=limit, offset=offset) - return {"items": [{"id": i.id, "authentik_sub": i.authentik_sub, "email": i.email, "display_name": i.display_name, "is_active": i.is_active} for i in items], "total": total, "limit": limit, "offset": offset} + return { + "items": [ + { + "id": i.id, + "authentik_sub": i.authentik_sub, + "username": i.username, + "email": i.email, + "display_name": i.display_name, + "is_active": i.is_active, + } + for i in items + ], + "total": total, + "limit": limit, + "offset": offset, + } diff --git a/app/api/me.py b/app/api/me.py index 05285fa..1ec711d 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -22,6 +22,7 @@ def get_me( users_repo = UsersRepository(db) user = users_repo.upsert_by_sub( authentik_sub=principal.sub, + username=principal.preferred_username, email=principal.email, display_name=principal.name or principal.preferred_username, is_active=True, @@ -47,6 +48,7 @@ def get_my_permission_snapshot( user = users_repo.upsert_by_sub( authentik_sub=principal.sub, + username=principal.preferred_username, email=principal.email, display_name=principal.name or principal.preferred_username, is_active=True, diff --git a/app/models/user.py b/app/models/user.py index 00cf559..410cead 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -14,6 +14,7 @@ class User(Base): id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) authentik_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) authentik_user_id: Mapped[int | None] = mapped_column(Integer) + username: Mapped[str | None] = mapped_column(String(255), unique=True) email: Mapped[str | None] = mapped_column(String(320)) display_name: Mapped[str | None] = mapped_column(String(255)) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) diff --git a/app/repositories/users_repo.py b/app/repositories/users_repo.py index a45c690..1517084 100644 --- a/app/repositories/users_repo.py +++ b/app/repositories/users_repo.py @@ -28,7 +28,12 @@ class UsersRepository: if keyword: pattern = f"%{keyword}%" - cond = or_(User.authentik_sub.ilike(pattern), User.email.ilike(pattern), User.display_name.ilike(pattern)) + cond = or_( + User.authentik_sub.ilike(pattern), + User.username.ilike(pattern), + User.email.ilike(pattern), + User.display_name.ilike(pattern), + ) stmt = stmt.where(cond) count_stmt = count_stmt.where(cond) @@ -44,6 +49,7 @@ class UsersRepository: def upsert_by_sub( self, authentik_sub: str, + username: str | None, email: str | None, display_name: str | None, is_active: bool, @@ -54,6 +60,7 @@ class UsersRepository: user = User( authentik_sub=authentik_sub, authentik_user_id=authentik_user_id, + username=username, email=email, display_name=display_name, is_active=is_active, @@ -62,6 +69,7 @@ class UsersRepository: else: if authentik_user_id is not None: user.authentik_user_id = authentik_user_id + user.username = username user.email = email user.display_name = display_name user.is_active = is_active @@ -74,10 +82,13 @@ class UsersRepository: self, user: User, *, + username: str | None = None, email: str | None = None, display_name: str | None = None, is_active: bool | None = None, ) -> User: + if username is not None: + user.username = username if email is not None: user.email = email if display_name is not None: diff --git a/app/schemas/authentik_admin.py b/app/schemas/authentik_admin.py index 295b154..6d06c0b 100644 --- a/app/schemas/authentik_admin.py +++ b/app/schemas/authentik_admin.py @@ -2,7 +2,8 @@ from pydantic import BaseModel class AuthentikEnsureUserRequest(BaseModel): - sub: str + sub: str | None = None + username: str | None = None email: str display_name: str | None = None is_active: bool = True diff --git a/app/schemas/catalog.py b/app/schemas/catalog.py index 09b2730..70b2000 100644 --- a/app/schemas/catalog.py +++ b/app/schemas/catalog.py @@ -78,6 +78,7 @@ class SiteItem(BaseModel): class MemberItem(BaseModel): id: str authentik_sub: str + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool @@ -85,6 +86,7 @@ class MemberItem(BaseModel): class MemberUpsertRequest(BaseModel): authentik_sub: str | None = None + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool = True @@ -92,12 +94,18 @@ class MemberUpsertRequest(BaseModel): class MemberUpdateRequest(BaseModel): + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool | None = None sync_to_authentik: bool = True +class MemberPasswordResetResponse(BaseModel): + authentik_sub: str + temporary_password: str + + class MemberPermissionGroupsUpdateRequest(BaseModel): group_keys: list[str] diff --git a/app/schemas/users.py b/app/schemas/users.py index b63f6b2..9bfd151 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -3,6 +3,7 @@ from pydantic import BaseModel class UserUpsertBySubRequest(BaseModel): sub: str + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool = True diff --git a/app/services/authentik_admin_service.py b/app/services/authentik_admin_service.py index d1dcb9b..20fd938 100644 --- a/app/services/authentik_admin_service.py +++ b/app/services/authentik_admin_service.py @@ -1,6 +1,8 @@ from __future__ import annotations from dataclasses import dataclass +import secrets +import string import httpx from fastapi import HTTPException, status @@ -15,6 +17,12 @@ class AuthentikSyncResult: authentik_sub: str | None = None +@dataclass +class AuthentikPasswordResetResult: + user_id: int + temporary_password: str + + class AuthentikAdminService: def __init__(self, settings: Settings) -> None: self.base_url = settings.authentik_base_url.rstrip("/") @@ -40,27 +48,76 @@ class AuthentikAdminService: ) @staticmethod - def _safe_username(sub: str, email: str) -> str: + def _safe_username(sub: str | None, email: str) -> str: if email and "@" in email: return email.split("@", 1)[0] - return sub.replace("|", "_")[:150] + if sub: + return sub.replace("|", "_")[:150] + return "member-user" - def ensure_user(self, sub: str, email: str, display_name: str | None, is_active: bool = True) -> AuthentikSyncResult: + @staticmethod + def _generate_temporary_password(length: int = 14) -> str: + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + return "".join(secrets.choice(alphabet) for _ in range(length)) + + @staticmethod + def _extract_first_result(data: dict) -> dict | None: + results = data.get("results") if isinstance(data, dict) else None + return results[0] if isinstance(results, list) and results else None + + def _lookup_user_by_id(self, client: httpx.Client, user_id: int) -> dict | None: + resp = client.get(f"/api/v3/core/users/{user_id}/") + if resp.status_code == 404: + return None + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + return resp.json() + + def _lookup_user_by_email_or_username( + self, client: httpx.Client, *, email: str | None, username: str | None + ) -> dict | None: + if email: + resp = client.get("/api/v3/core/users/", params={"email": email}) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + existing = self._extract_first_result(resp.json()) + if existing: + return existing + + if username: + resp = client.get("/api/v3/core/users/", params={"username": username}) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + existing = self._extract_first_result(resp.json()) + if existing: + return existing + + return None + + def ensure_user( + self, + *, + sub: str | None, + email: str, + username: str | None, + display_name: str | None, + is_active: bool = True, + authentik_user_id: int | None = None, + ) -> AuthentikSyncResult: + resolved_username = username or self._safe_username(sub=sub, email=email) payload = { - "username": self._safe_username(sub=sub, email=email), + "username": resolved_username, "name": display_name or email, "email": email, "is_active": is_active, } with self._client() as client: - resp = client.get("/api/v3/core/users/", params={"email": email}) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="authentik_lookup_failed") - - data = resp.json() - results = data.get("results") if isinstance(data, dict) else None - existing = results[0] if isinstance(results, list) and results else None + existing = None + if authentik_user_id is not None: + existing = self._lookup_user_by_id(client, authentik_user_id) + if existing is None: + existing = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username) if existing and existing.get("pk") is not None: user_pk = int(existing["pk"]) @@ -78,3 +135,27 @@ class AuthentikAdminService: action="created", authentik_sub=created.get("uid"), ) + + def reset_password( + self, + *, + authentik_user_id: int | None, + email: str | None, + username: str | None, + ) -> AuthentikPasswordResetResult: + with self._client() as client: + existing = None + if authentik_user_id is not None: + existing = self._lookup_user_by_id(client, authentik_user_id) + if existing is None: + existing = self._lookup_user_by_email_or_username(client, email=email, username=username) + if not existing or existing.get("pk") is None: + raise HTTPException(status_code=404, detail="authentik_user_not_found") + + user_pk = int(existing["pk"]) + temp_password = self._generate_temporary_password() + set_pwd_resp = client.post(f"/api/v3/core/users/{user_pk}/set_password/", json={"password": temp_password}) + if set_pwd_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_set_password_failed") + + return AuthentikPasswordResetResult(user_id=user_pk, temporary_password=temp_password) diff --git a/scripts/init_schema.sql b/scripts/init_schema.sql index 3a74852..07e5258 100644 --- a/scripts/init_schema.sql +++ b/scripts/init_schema.sql @@ -21,6 +21,7 @@ CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), authentik_sub TEXT NOT NULL UNIQUE, authentik_user_id INTEGER, + username TEXT UNIQUE, email TEXT UNIQUE, display_name TEXT, status VARCHAR(16) NOT NULL DEFAULT 'active', @@ -144,6 +145,7 @@ VALUES ('member', 'Member Center', 'active') ON CONFLICT (system_key) DO NOTHING; CREATE INDEX idx_users_authentik_sub ON users(authentik_sub); +CREATE INDEX idx_users_username ON users(username); CREATE INDEX idx_sites_company_id ON sites(company_id); CREATE INDEX idx_usp_user_id ON user_scope_permissions(user_id); CREATE INDEX idx_usp_module_id ON user_scope_permissions(module_id); diff --git a/scripts/migrate_add_users_username.sql b/scripts/migrate_add_users_username.sql new file mode 100644 index 0000000..ee1c0c1 --- /dev/null +++ b/scripts/migrate_add_users_username.sql @@ -0,0 +1,16 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS username TEXT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'uq_users_username' + ) THEN + ALTER TABLE users + ADD CONSTRAINT uq_users_username UNIQUE (username); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);