diff --git a/backend/README.md b/backend/README.md index 62ae29c..8e5eb6a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -8,6 +8,7 @@ python -m venv .venv source .venv/bin/activate pip install -e . cp .env.example .env +psql "$DATABASE_URL" -f scripts/init_schema.sql ./scripts/start_dev.sh ``` @@ -36,6 +37,25 @@ cp .env.example .env - `POST /auth/oidc/exchange` - `GET /me` (Bearer token required) - `GET /me/permissions/snapshot` (Bearer token required) + +### Admin APIs (Bearer + admin group required) +- `GET/POST/PATCH/DELETE /admin/companies` +- `GET/POST/PATCH/DELETE /admin/sites` +- `GET/POST/PATCH/DELETE /admin/systems` +- `GET/POST/PATCH/DELETE /admin/roles` +- `GET/POST/PATCH/DELETE /admin/members` +- `PUT /admin/sites/{site_key}/roles` +- `PUT /admin/members/{user_sub}/sites` +- `GET /admin/members/{user_sub}/roles` +- `GET/POST/PATCH/DELETE /admin/api-clients` + +### Internal APIs (`X-Client-Key` + `X-API-Key`) +- `GET /internal/companies` +- `GET /internal/sites` +- `GET /internal/systems` +- `GET /internal/roles` +- `GET /internal/members` - `POST /internal/users/upsert-by-sub` +- `GET /internal/users/{user_sub}/roles` - `GET /internal/permissions/{user_sub}/snapshot` - `POST /internal/idp/users/ensure` diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py deleted file mode 100644 index 7a393b3..0000000 --- a/backend/app/api/admin.py +++ /dev/null @@ -1,153 +0,0 @@ -from uuid import UUID - -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.repositories.companies_repo import CompaniesRepository -from app.repositories.modules_repo import ModulesRepository -from app.repositories.permissions_repo import PermissionsRepository -from app.repositories.sites_repo import SitesRepository -from app.repositories.systems_repo import SystemsRepository -from app.repositories.users_repo import UsersRepository -from app.schemas.permissions import ( - DirectPermissionListResponse, - DirectPermissionRow, - PermissionGrantRequest, - PermissionRevokeRequest, -) -from app.security.admin_guard import require_admin_principal - -router = APIRouter( - prefix="/admin", - tags=["admin"], - dependencies=[Depends(require_admin_principal)], -) - - -def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str: - systems_repo = SystemsRepository(db) - modules_repo = ModulesRepository(db) - system = systems_repo.get_by_key(system_key) - if not system: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - - target_module_key = module_key if module_key else f"__system__{system_key}" - module = modules_repo.get_by_key(target_module_key) - if module and module.system_key != system_key: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="module_system_mismatch") - if not module: - module = modules_repo.create( - module_key=target_module_key, - system_key=system_key, - name=target_module_key, - status="active", - ) - return module.id - - -def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]: - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - if scope_type == "company": - company = companies_repo.get_by_key(scope_id) - if not company: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - return company.id, None - if scope_type == "site": - site = sites_repo.get_by_key(scope_id) - if not site: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") - return None, site.id - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type") - - -@router.post("/permissions/grant") -def grant_permission( - payload: PermissionGrantRequest, - db: Session = Depends(get_db), -) -> dict[str, str]: - users_repo = UsersRepository(db) - perms_repo = PermissionsRepository(db) - - user = users_repo.upsert_by_sub( - user_sub=payload.user_sub, - username=None, - email=payload.email, - display_name=payload.display_name, - is_active=True, - ) - module_id = _resolve_module_id(db, payload.system, payload.module) - company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id) - permission = perms_repo.create_if_not_exists( - user_id=user.id, - module_id=module_id, - action=payload.action, - scope_type=payload.scope_type, - company_id=company_id, - site_id=site_id, - ) - - return {"permission_id": permission.id, "result": "granted"} - - -@router.post("/permissions/revoke") -def revoke_permission( - payload: PermissionRevokeRequest, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - users_repo = UsersRepository(db) - perms_repo = PermissionsRepository(db) - - user = users_repo.get_by_sub(payload.user_sub) - if user is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") - - module_id = _resolve_module_id(db, payload.system, payload.module) - company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id) - deleted = perms_repo.revoke( - user_id=user.id, - module_id=module_id, - action=payload.action, - scope_type=payload.scope_type, - company_id=company_id, - site_id=site_id, - ) - return {"deleted": deleted, "result": "revoked"} - - -@router.get("/permissions/direct", response_model=DirectPermissionListResponse) -def list_direct_permissions( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - scope_type: str | None = Query(default=None), - limit: int = Query(default=200, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> DirectPermissionListResponse: - perms_repo = PermissionsRepository(db) - items, total = perms_repo.list_direct_permissions( - keyword=keyword, - scope_type=scope_type, - limit=limit, - offset=offset, - ) - return DirectPermissionListResponse( - items=[DirectPermissionRow(**item) for item in items], - total=total, - limit=limit, - offset=offset, - ) - - -@router.delete("/permissions/direct/{permission_id}") -def delete_direct_permission( - permission_id: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - try: - normalized_permission_id = str(UUID(permission_id)) - except ValueError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_permission_id") - perms_repo = PermissionsRepository(db) - deleted = perms_repo.revoke_by_permission_id(normalized_permission_id) - return {"deleted": deleted, "result": "revoked"} diff --git a/backend/app/api/admin_catalog.py b/backend/app/api/admin_catalog.py index c56ab03..ebd3768 100644 --- a/backend/app/api/admin_catalog.py +++ b/backend/app/api/admin_catalog.py @@ -1,59 +1,58 @@ +from __future__ import annotations + import secrets + from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.keygen import generate_key -from app.core.config import get_settings from app.db.session import get_db -from app.models.api_client import ApiClient -from app.models.permission_group_member import PermissionGroupMember -from app.models.permission_group_permission import PermissionGroupPermission +from app.repositories.api_clients_repo import ApiClientsRepository from app.repositories.companies_repo import CompaniesRepository -from app.repositories.modules_repo import ModulesRepository -from app.repositories.permission_groups_repo import PermissionGroupsRepository +from app.repositories.roles_repo import RolesRepository +from app.repositories.site_roles_repo import SiteRolesRepository from app.repositories.sites_repo import SitesRepository from app.repositories.systems_repo import SystemsRepository from app.repositories.users_repo import UsersRepository +from app.repositories.user_sites_repo import UserSitesRepository +from app.schemas.api_clients import ApiClientCreateRequest, ApiClientCreateResponse, ApiClientRotateKeyResponse, ApiClientUpdateRequest from app.schemas.catalog import ( + ApiClientItem, CompanyCreateRequest, CompanyItem, - GroupBindingSnapshot, - GroupBindingUpdateRequest, - GroupRelationItem, - MemberRelationItem, + CompanySitesResponse, CompanyUpdateRequest, + ListResponse, MemberItem, - MemberPermissionGroupsResponse, - MemberPermissionGroupsUpdateRequest, MemberPasswordResetResponse, - MemberUpdateRequest, MemberUpsertRequest, - ModuleCreateRequest, - ModuleItem, - ModuleUpdateRequest, - PermissionGroupCreateRequest, - PermissionGroupItem, - PermissionGroupPermissionItem, - PermissionGroupUpdateRequest, + MemberUpdateRequest, + RoleCreateRequest, + RoleItem, + RoleSitesResponse, + RoleUpdateRequest, SiteCreateRequest, SiteItem, + SiteMembersResponse, + SiteRoleAssignRequest, + SiteRoleItem, + SiteRolesResponse, SiteUpdateRequest, SystemCreateRequest, SystemItem, + SystemRolesResponse, SystemUpdateRequest, + UserEffectiveRoleItem, + UserEffectiveRolesResponse, + UserSiteAssignRequest, + UserSiteItem, + UserSitesResponse, ) -from app.schemas.api_clients import ( - ApiClientCreateRequest, - ApiClientCreateResponse, - ApiClientItem, - ApiClientRotateKeyResponse, - ApiClientUpdateRequest, -) -from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest from app.security.admin_guard import require_admin_principal from app.security.api_client_auth import hash_api_key from app.services.idp_admin_service import KeycloakAdminService +from app.core.config import get_settings router = APIRouter( prefix="/admin", @@ -62,1038 +61,761 @@ router = APIRouter( ) -def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str: - systems_repo = SystemsRepository(db) - modules_repo = ModulesRepository(db) - system = systems_repo.get_by_key(system_key) - if not system: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - target_module_key = module_key if module_key else f"__system__{system_key}" - module = modules_repo.get_by_key(target_module_key) - if module and module.system_key != system_key: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="module_system_mismatch") - if not module: - module = modules_repo.create( - module_key=target_module_key, - system_key=system_key, - name=target_module_key, - status="active", - ) - return module.id - - -def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]: - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - if scope_type == "company": - company = companies_repo.get_by_key(scope_id) - if not company: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - return company.id, None - if scope_type == "site": - site = sites_repo.get_by_key(scope_id) - if not site: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") - return None, site.id - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type") - - -def _split_module_key(payload_module: str | None) -> str: - if not payload_module: - return "__system__" - return payload_module - - -def _generate_unique_key(prefix: str, exists_fn) -> str: - for salt in range(1000): - key = generate_key(prefix=prefix, salt=salt) - if not exists_fn(key): +def _generate_unique_key(prefix: str, exists_check) -> str: + for salt in range(5000): + key = generate_key(prefix, salt) + if not exists_check(key): return key - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_to_generate_{prefix.lower()}_key") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_generate_{prefix.lower()}_key") -def _serialize_api_client(item: ApiClient) -> ApiClientItem: - return ApiClientItem( - id=item.id, - client_key=item.client_key, - name=item.name, - status=item.status, - allowed_origins=item.allowed_origins or [], - allowed_ips=item.allowed_ips or [], - allowed_paths=item.allowed_paths or [], - rate_limit_per_min=item.rate_limit_per_min, - expires_at=item.expires_at, - last_used_at=item.last_used_at, - created_at=item.created_at, - updated_at=item.updated_at, +def _company_item(company) -> CompanyItem: + return CompanyItem( + id=company.id, + company_key=company.company_key, + display_name=company.display_name, + legal_name=company.legal_name, + idp_group_id=company.idp_group_id, + status=company.status, ) -def _generate_api_key() -> str: - return secrets.token_urlsafe(36) - - -def _sync_member_to_idp( - *, - user_sub: str | None, - idp_user_id: str | None, - username: str | None, - email: str | None, - display_name: str | None, - is_active: bool, -) -> dict[str, str]: - if not email: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync") - settings = get_settings() - service = KeycloakAdminService(settings=settings) - result = service.ensure_user( - sub=user_sub, - email=email, - username=username, - display_name=display_name, - is_active=is_active, - idp_user_id=idp_user_id, +def _site_item(site, company) -> SiteItem: + return SiteItem( + id=site.id, + site_key=site.site_key, + company_key=company.company_key, + company_display_name=company.display_name, + display_name=site.display_name, + domain=site.domain, + idp_group_id=site.idp_group_id, + status=site.status, ) - return { - "idp_user_id": result.user_id, - "sync_action": result.action, - "user_sub": result.user_sub or "", - } -@router.get("/systems") -def list_systems( - db: Session = Depends(get_db), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> dict: - repo = SystemsRepository(db) - items, total = repo.list(limit=limit, offset=offset) - return {"items": [SystemItem(id=i.id, system_key=i.system_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset} - - -@router.post("/systems", response_model=SystemItem) -def create_system( - payload: SystemCreateRequest, - db: Session = Depends(get_db), -) -> SystemItem: - repo = SystemsRepository(db) - system_key = _generate_unique_key("ST", repo.get_by_key) - row = repo.create(system_key=system_key, name=payload.name, status=payload.status) - return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status) - - -@router.patch("/systems/{system_key}", response_model=SystemItem) -def update_system( - system_key: str, - payload: SystemUpdateRequest, - db: Session = Depends(get_db), -) -> SystemItem: - repo = SystemsRepository(db) - row = repo.get_by_key(system_key) - if not row: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - row = repo.update(row, name=payload.name, status=payload.status) - return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status) - - -@router.delete("/systems/{system_key}") -def delete_system( - system_key: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - repo = SystemsRepository(db) - row = repo.get_by_key(system_key) - if not row: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - db.execute(delete(PermissionGroupPermission).where(PermissionGroupPermission.system == system_key)) - db.delete(row) - db.commit() - return {"deleted": 1, "result": "deleted"} - - -@router.get("/modules") -def list_modules( - db: Session = Depends(get_db), - limit: int = Query(default=200, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> dict: - modules_repo = ModulesRepository(db) - items, total = modules_repo.list(limit=limit, offset=offset) - out = [] - for i in items: - if i.module_key.startswith("__system__"): - continue - out.append( - ModuleItem( - id=i.id, - system_key=i.system_key, - module_key=i.module_key, - name=i.name, - status=i.status, - ).model_dump() - ) - return {"items": out, "total": total, "limit": limit, "offset": offset} - - -@router.post("/modules", response_model=ModuleItem) -def create_module( - payload: ModuleCreateRequest, - db: Session = Depends(get_db), -) -> ModuleItem: - systems_repo = SystemsRepository(db) - modules_repo = ModulesRepository(db) - system = systems_repo.get_by_key(payload.system_key) - if not system: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - leaf_module_key = _generate_unique_key("MD", modules_repo.get_by_key) - row = modules_repo.create( - module_key=leaf_module_key, - system_key=payload.system_key, - name=payload.name, - status=payload.status, +def _system_item(system) -> SystemItem: + return SystemItem( + id=system.id, + system_key=system.system_key, + name=system.name, + idp_client_id=system.idp_client_id, + status=system.status, ) - return ModuleItem(id=row.id, system_key=row.system_key, module_key=row.module_key, name=row.name, status=row.status) -@router.patch("/modules/{module_key}") -def update_module( - module_key: str, - payload: ModuleUpdateRequest, - db: Session = Depends(get_db), -) -> ModuleItem: - modules_repo = ModulesRepository(db) - row = modules_repo.get_by_key(module_key) - if not row: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found") - row = modules_repo.update(row, name=payload.name, status=payload.status) - return ModuleItem(id=row.id, system_key=row.system_key, module_key=row.module_key, name=row.name, status=row.status) +def _member_item(user) -> MemberItem: + return MemberItem( + id=user.id, + user_sub=user.user_sub, + idp_user_id=user.idp_user_id, + username=user.username, + email=user.email, + display_name=user.display_name, + is_active=user.is_active, + status=user.status, + ) -@router.delete("/modules/{module_key}") -def delete_module( - module_key: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - modules_repo = ModulesRepository(db) - row = modules_repo.get_by_key(module_key) - if not row: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found") - db.execute(delete(PermissionGroupPermission).where(PermissionGroupPermission.module == module_key)) - db.delete(row) - db.commit() - return {"deleted": 1, "result": "deleted"} - - -@router.get("/systems/{system_key}/groups") -def list_system_groups( - system_key: str, - db: Session = Depends(get_db), -) -> dict[str, list[dict]]: - systems_repo = SystemsRepository(db) - groups_repo = PermissionGroupsRepository(db) - if not systems_repo.get_by_key(system_key): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - groups = groups_repo.list_system_groups(system_key) - return { - "items": [ - GroupRelationItem(group_key=g.group_key, group_name=g.name, status=g.status).model_dump() - for g in groups - ] - } - - -@router.get("/systems/{system_key}/members") -def list_system_members( - system_key: str, - db: Session = Depends(get_db), -) -> dict[str, list[dict]]: - systems_repo = SystemsRepository(db) - groups_repo = PermissionGroupsRepository(db) - if not systems_repo.get_by_key(system_key): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - members = groups_repo.list_system_members(system_key) - return { - "items": [ - MemberRelationItem( - user_sub=m.user_sub, - email=m.email, - display_name=m.display_name, - is_active=m.is_active, - ).model_dump() - for m in members - ] - } - - -@router.get("/modules/{module_key}/groups") -def list_module_groups( - module_key: str, - db: Session = Depends(get_db), -) -> dict[str, list[dict]]: - modules_repo = ModulesRepository(db) - groups_repo = PermissionGroupsRepository(db) - module = modules_repo.get_by_key(module_key) - if not module: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found") - groups = groups_repo.list_module_groups(module.system_key, module.module_key) - return { - "items": [ - GroupRelationItem(group_key=g.group_key, group_name=g.name, status=g.status).model_dump() - for g in groups - ] - } - - -@router.get("/modules/{module_key}/members") -def list_module_members( - module_key: str, - db: Session = Depends(get_db), -) -> dict[str, list[dict]]: - modules_repo = ModulesRepository(db) - groups_repo = PermissionGroupsRepository(db) - module = modules_repo.get_by_key(module_key) - if not module: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="module_not_found") - members = groups_repo.list_module_members(module.system_key, module.module_key) - return { - "items": [ - MemberRelationItem( - user_sub=m.user_sub, - email=m.email, - display_name=m.display_name, - is_active=m.is_active, - ).model_dump() - for m in members - ] - } - - -@router.get("/companies") +@router.get("/companies", response_model=ListResponse) def list_companies( db: Session = Depends(get_db), keyword: str | None = Query(default=None), limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), -) -> dict: +) -> ListResponse: repo = CompaniesRepository(db) items, total = repo.list(keyword=keyword, limit=limit, offset=offset) - return {"items": [CompanyItem(id=i.id, company_key=i.company_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset} + return ListResponse(items=[_company_item(i) for i in items], total=total, limit=limit, offset=offset) @router.post("/companies", response_model=CompanyItem) -def create_company( - payload: CompanyCreateRequest, - db: Session = Depends(get_db), -) -> CompanyItem: +def create_company(payload: CompanyCreateRequest, db: Session = Depends(get_db)) -> CompanyItem: repo = CompaniesRepository(db) - company_key = _generate_unique_key("CP", repo.get_by_key) - row = repo.create(company_key=company_key, name=payload.name, status=payload.status) - return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status) + company_key = _generate_unique_key("CP", lambda key: repo.get_by_key(key) is not None) + item = repo.create(company_key=company_key, display_name=payload.display_name, legal_name=payload.legal_name, status=payload.status) + return _company_item(item) @router.patch("/companies/{company_key}", response_model=CompanyItem) -def update_company( - company_key: str, - payload: CompanyUpdateRequest, - db: Session = Depends(get_db), -) -> CompanyItem: +def update_company(company_key: str, payload: CompanyUpdateRequest, db: Session = Depends(get_db)) -> CompanyItem: repo = CompaniesRepository(db) - row = repo.get_by_key(company_key) - if not row: + item = repo.get_by_key(company_key) + if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - row = repo.update(row, name=payload.name, status=payload.status) - return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status) + item = repo.update( + item, + display_name=payload.display_name, + legal_name=payload.legal_name, + idp_group_id=payload.idp_group_id, + status=payload.status, + ) + return _company_item(item) @router.delete("/companies/{company_key}") -def delete_company( - company_key: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: +def delete_company(company_key: str, db: Session = Depends(get_db)) -> dict[str, str]: + repo = CompaniesRepository(db) + item = repo.get_by_key(company_key) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") + repo.delete(item) + return {"deleted": company_key} + + +@router.get("/companies/{company_key}/sites", response_model=CompanySitesResponse) +def list_company_sites(company_key: str, db: Session = Depends(get_db)) -> CompanySitesResponse: companies_repo = CompaniesRepository(db) sites_repo = SitesRepository(db) company = companies_repo.get_by_key(company_key) if not company: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - company_sites, _ = sites_repo.list(company_id=company.id, limit=10000, offset=0) - company_site_keys = [s.site_key for s in company_sites] - if company_site_keys: - db.execute( - delete(PermissionGroupPermission).where( - PermissionGroupPermission.scope_type == "site", - PermissionGroupPermission.scope_id.in_(company_site_keys), - ) - ) - db.delete(company) - db.commit() - return {"deleted": 1, "result": "deleted"} + sites, _ = sites_repo.list(company_id=company.id, limit=2000, offset=0) + return CompanySitesResponse(company_key=company_key, sites=[_site_item(site, company) for site in sites]) -@router.get("/companies/{company_key}/sites") -def list_company_sites( - company_key: str, - db: Session = Depends(get_db), -) -> dict[str, list[dict]]: - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - company = companies_repo.get_by_key(company_key) - if not company: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - items, _ = sites_repo.list(company_id=company.id, limit=1000, offset=0) - return { - "items": [ - SiteItem( - id=i.id, - site_key=i.site_key, - company_key=company.company_key, - name=i.name, - status=i.status, - ).model_dump() - for i in items - ] - } - - -@router.get("/sites") +@router.get("/sites", response_model=ListResponse) def list_sites( db: Session = Depends(get_db), - company_key: str | None = Query(default=None), keyword: str | None = Query(default=None), + company_key: str | None = Query(default=None), limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), -) -> dict: +) -> ListResponse: companies_repo = CompaniesRepository(db) sites_repo = SitesRepository(db) - company_lookup: dict[str, str] = {} - all_companies, _ = companies_repo.list(limit=1000, offset=0) - for c in all_companies: - company_lookup[c.id] = c.company_key company_id = None if company_key: company = companies_repo.get_by_key(company_key) if not company: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") + return ListResponse(items=[], total=0, limit=limit, offset=offset) company_id = company.id + + companies, _ = companies_repo.list(limit=5000, offset=0) + company_map = {c.id: c for c in companies} items, total = sites_repo.list(keyword=keyword, company_id=company_id, limit=limit, offset=offset) - return { - "items": [ - SiteItem( - id=i.id, - site_key=i.site_key, - company_key=company_lookup.get(i.company_id, ""), - name=i.name, - status=i.status, - ).model_dump() - for i in items - ], - "total": total, - "limit": limit, - "offset": offset, - } + response_items = [_site_item(i, company_map[i.company_id]) for i in items if i.company_id in company_map] + return ListResponse(items=response_items, total=total, limit=limit, offset=offset) @router.post("/sites", response_model=SiteItem) -def create_site( - payload: SiteCreateRequest, - db: Session = Depends(get_db), -) -> SiteItem: +def create_site(payload: SiteCreateRequest, db: Session = Depends(get_db)) -> SiteItem: companies_repo = CompaniesRepository(db) sites_repo = SitesRepository(db) company = companies_repo.get_by_key(payload.company_key) if not company: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - site_key = _generate_unique_key("ST", sites_repo.get_by_key) - row = sites_repo.create(site_key=site_key, company_id=company.id, name=payload.name, status=payload.status) - return SiteItem(id=row.id, site_key=row.site_key, company_key=payload.company_key, name=row.name, status=row.status) + + site_key = _generate_unique_key("ST", lambda key: sites_repo.get_by_key(key) is not None) + item = sites_repo.create( + site_key=site_key, + company_id=company.id, + display_name=payload.display_name, + domain=payload.domain, + status=payload.status, + ) + return _site_item(item, company) @router.patch("/sites/{site_key}", response_model=SiteItem) -def update_site( - site_key: str, - payload: SiteUpdateRequest, - db: Session = Depends(get_db), -) -> SiteItem: +def update_site(site_key: str, payload: SiteUpdateRequest, db: Session = Depends(get_db)) -> SiteItem: companies_repo = CompaniesRepository(db) sites_repo = SitesRepository(db) - row = sites_repo.get_by_key(site_key) - if not row: + + item = sites_repo.get_by_key(site_key) + if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") + company_id = None - company_key = None - if payload.company_key is not None: + if payload.company_key: company = companies_repo.get_by_key(payload.company_key) if not company: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") company_id = company.id - company_key = company.company_key - row = sites_repo.update(row, company_id=company_id, name=payload.name, status=payload.status) - if company_key is None: - current_company = companies_repo.get_by_id(row.company_id) - company_key = current_company.company_key if current_company else "" - return SiteItem(id=row.id, site_key=row.site_key, company_key=company_key, name=row.name, status=row.status) + + item = sites_repo.update( + item, + company_id=company_id, + display_name=payload.display_name, + domain=payload.domain, + idp_group_id=payload.idp_group_id, + status=payload.status, + ) + company = companies_repo.get_by_id(item.company_id) + if not company: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="company_reference_missing") + return _site_item(item, company) @router.delete("/sites/{site_key}") -def delete_site( - site_key: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - sites_repo = SitesRepository(db) - row = sites_repo.get_by_key(site_key) - if not row: +def delete_site(site_key: str, db: Session = Depends(get_db)) -> dict[str, str]: + repo = SitesRepository(db) + item = repo.get_by_key(site_key) + if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") - db.execute( - delete(PermissionGroupPermission).where( - PermissionGroupPermission.scope_type == "site", - PermissionGroupPermission.scope_id == site_key, + repo.delete(item) + return {"deleted": site_key} + + +@router.get("/systems", response_model=ListResponse) +def list_systems( + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> ListResponse: + repo = SystemsRepository(db) + items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) + return ListResponse(items=[_system_item(i) for i in items], total=total, limit=limit, offset=offset) + + +@router.post("/systems", response_model=SystemItem) +def create_system(payload: SystemCreateRequest, db: Session = Depends(get_db)) -> SystemItem: + repo = SystemsRepository(db) + system_key = _generate_unique_key("SY", lambda key: repo.get_by_key(key) is not None) + item = repo.create(system_key=system_key, name=payload.name, idp_client_id=payload.idp_client_id, status=payload.status) + return _system_item(item) + + +@router.patch("/systems/{system_key}", response_model=SystemItem) +def update_system(system_key: str, payload: SystemUpdateRequest, db: Session = Depends(get_db)) -> SystemItem: + repo = SystemsRepository(db) + item = repo.get_by_key(system_key) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + item = repo.update(item, name=payload.name, idp_client_id=payload.idp_client_id, status=payload.status) + return _system_item(item) + + +@router.delete("/systems/{system_key}") +def delete_system(system_key: str, db: Session = Depends(get_db)) -> dict[str, str]: + repo = SystemsRepository(db) + item = repo.get_by_key(system_key) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + repo.delete(item) + return {"deleted": system_key} + + +@router.get("/roles", response_model=ListResponse) +def list_roles( + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + system_key: str | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> ListResponse: + systems_repo = SystemsRepository(db) + roles_repo = RolesRepository(db) + + system_id = None + system_map: dict[str, object] = {} + systems, _ = systems_repo.list(limit=5000, offset=0) + for system in systems: + system_map[system.id] = system + + if system_key: + system = systems_repo.get_by_key(system_key) + if not system: + return ListResponse(items=[], total=0, limit=limit, offset=offset) + system_id = system.id + + rows, total = roles_repo.list(keyword=keyword, system_id=system_id, status=status_filter, limit=limit, offset=offset) + items = [ + RoleItem( + id=row.id, + role_key=row.role_key, + system_key=system_map[row.system_id].system_key, + system_name=system_map[row.system_id].name, + name=row.name, + idp_role_name=row.idp_role_name, + description=row.description, + status=row.status, ) + for row in rows + if row.system_id in system_map + ] + return ListResponse(items=items, total=total, limit=limit, offset=offset) + + +@router.post("/roles", response_model=RoleItem) +def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> RoleItem: + systems_repo = SystemsRepository(db) + roles_repo = RolesRepository(db) + + system = systems_repo.get_by_key(payload.system_key) + if not system: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + + role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) + try: + row = roles_repo.create( + role_key=role_key, + system_id=system.id, + name=payload.name, + description=payload.description, + idp_role_name=payload.idp_role_name, + status=payload.status, + ) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="role_name_conflict") + + return RoleItem( + id=row.id, + role_key=row.role_key, + system_key=system.system_key, + system_name=system.name, + name=row.name, + idp_role_name=row.idp_role_name, + description=row.description, + status=row.status, ) - db.delete(row) - db.commit() - return {"deleted": 1, "result": "deleted"} -@router.get("/members") +@router.patch("/roles/{role_key}", response_model=RoleItem) +def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends(get_db)) -> RoleItem: + systems_repo = SystemsRepository(db) + roles_repo = RolesRepository(db) + + role = roles_repo.get_by_key(role_key) + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") + + system_id = None + if payload.system_key: + system = systems_repo.get_by_key(payload.system_key) + if not system: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + system_id = system.id + + try: + role = roles_repo.update( + role, + system_id=system_id, + name=payload.name, + description=payload.description, + idp_role_name=payload.idp_role_name, + status=payload.status, + ) + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="role_name_conflict") + + system = systems_repo.get_by_id(role.system_id) + if not system: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing") + + return RoleItem( + id=role.id, + role_key=role.role_key, + system_key=system.system_key, + system_name=system.name, + name=role.name, + idp_role_name=role.idp_role_name, + description=role.description, + status=role.status, + ) + + +@router.delete("/roles/{role_key}") +def delete_role(role_key: str, db: Session = Depends(get_db)) -> dict[str, str]: + repo = RolesRepository(db) + role = repo.get_by_key(role_key) + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") + repo.delete(role) + return {"deleted": role_key} + + +@router.get("/systems/{system_key}/roles", response_model=SystemRolesResponse) +def list_system_roles(system_key: str, db: Session = Depends(get_db)) -> SystemRolesResponse: + systems_repo = SystemsRepository(db) + roles_repo = RolesRepository(db) + + system = systems_repo.get_by_key(system_key) + if not system: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") + + rows, _ = roles_repo.list(system_id=system.id, limit=5000, offset=0) + return SystemRolesResponse( + system_key=system_key, + roles=[ + RoleItem( + id=row.id, + role_key=row.role_key, + system_key=system.system_key, + system_name=system.name, + name=row.name, + idp_role_name=row.idp_role_name, + description=row.description, + status=row.status, + ) + for row in rows + ], + ) + + +@router.get("/sites/{site_key}/roles", response_model=SiteRolesResponse) +def list_site_roles(site_key: str, db: Session = Depends(get_db)) -> SiteRolesResponse: + sites_repo = SitesRepository(db) + site_roles_repo = SiteRolesRepository(db) + + site = sites_repo.get_by_key(site_key) + if not site: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") + + rows = site_roles_repo.list_site_role_rows(site.id) + return SiteRolesResponse( + site_key=site_key, + roles=[ + SiteRoleItem( + id=site_role.id, + role_key=role.role_key, + role_name=role.name, + system_key=system.system_key, + system_name=system.name, + ) + for site_role, role, system in rows + ], + ) + + +@router.put("/sites/{site_key}/roles", response_model=SiteRolesResponse) +def assign_site_roles(site_key: str, payload: SiteRoleAssignRequest, db: Session = Depends(get_db)) -> SiteRolesResponse: + sites_repo = SitesRepository(db) + roles_repo = RolesRepository(db) + site_roles_repo = SiteRolesRepository(db) + + site = sites_repo.get_by_key(site_key) + if not site: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") + + role_ids: list[str] = [] + for role_key in list(dict.fromkeys(payload.role_keys)): + role = roles_repo.get_by_key(role_key) + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"role_not_found:{role_key}") + role_ids.append(role.id) + + site_roles_repo.set_site_roles(site_id=site.id, role_ids=role_ids) + return list_site_roles(site_key=site_key, db=db) + + +@router.get("/roles/{role_key}/sites", response_model=RoleSitesResponse) +def list_role_sites(role_key: str, db: Session = Depends(get_db)) -> RoleSitesResponse: + roles_repo = RolesRepository(db) + companies_repo = CompaniesRepository(db) + site_roles_repo = SiteRolesRepository(db) + + role = roles_repo.get_by_key(role_key) + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") + + rows = site_roles_repo.list_role_site_rows(role.id) + company_cache = {} + result = [] + for _, site in rows: + company = company_cache.get(site.company_id) + if company is None: + company = companies_repo.get_by_id(site.company_id) + company_cache[site.company_id] = company + if not company: + continue + result.append( + UserSiteItem( + id=site.id, + site_key=site.site_key, + site_display_name=site.display_name, + company_key=company.company_key, + company_display_name=company.display_name, + ) + ) + + return RoleSitesResponse(role_key=role_key, sites=result) + + +@router.get("/members", response_model=ListResponse) def list_members( db: Session = Depends(get_db), keyword: str | None = Query(default=None), + is_active: bool | None = Query(default=None), limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), -) -> dict: - users_repo = UsersRepository(db) - items, total = users_repo.list(keyword=keyword, limit=limit, offset=offset) - return { - "items": [ - MemberItem( - id=i.id, - user_sub=i.user_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, - } +) -> ListResponse: + repo = UsersRepository(db) + rows, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset) + return ListResponse(items=[_member_item(r) for r in rows], total=total, limit=limit, offset=offset) -@router.post("/members/upsert", response_model=MemberItem) -def upsert_member( - payload: MemberUpsertRequest, - db: Session = Depends(get_db), -) -> MemberItem: +@router.post("/members", response_model=MemberItem) +def create_member(payload: MemberUpsertRequest, db: Session = Depends(get_db)) -> MemberItem: users_repo = UsersRepository(db) + resolved_sub = payload.user_sub - resolved_username = payload.username - idp_user_id = None + idp_user_id: str | None = None if payload.sync_to_idp: - seed_sub = payload.user_sub or payload.username - if not seed_sub: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_sub_or_username_required") - sync = _sync_member_to_idp( - user_sub=seed_sub, - idp_user_id=idp_user_id, - username=payload.username, + if not payload.email: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync") + idp = KeycloakAdminService(get_settings()) + sync_result = idp.ensure_user( + sub=payload.user_sub, email=payload.email, + username=payload.username, display_name=payload.display_name, is_active=payload.is_active, ) - idp_user_id = str(sync["idp_user_id"]) - if sync.get("user_sub"): - resolved_sub = str(sync["user_sub"]) + idp_user_id = sync_result.user_id + resolved_sub = resolved_sub or sync_result.user_sub + if not resolved_sub: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_sub_required") - row = users_repo.upsert_by_sub( + + user = users_repo.upsert_by_sub( user_sub=resolved_sub, - username=resolved_username, + username=payload.username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, + status=payload.status, idp_user_id=idp_user_id, ) - return MemberItem( - id=row.id, - user_sub=row.user_sub, - username=row.username, - email=row.email, - display_name=row.display_name, - is_active=row.is_active, - ) + return _member_item(user) @router.patch("/members/{user_sub}", response_model=MemberItem) -def update_member( - user_sub: str, - payload: MemberUpdateRequest, - db: Session = Depends(get_db), -) -> MemberItem: +def update_member(user_sub: str, payload: MemberUpdateRequest, db: Session = Depends(get_db)) -> MemberItem: users_repo = UsersRepository(db) - row = users_repo.get_by_sub(user_sub) - if not row: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + user = users_repo.get_by_sub(user_sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_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 + next_username = payload.username if payload.username is not None else user.username + next_email = payload.email if payload.email is not None else user.email + next_display_name = payload.display_name if payload.display_name is not None else user.display_name + next_is_active = payload.is_active if payload.is_active is not None else user.is_active - idp_user_id = row.idp_user_id if payload.sync_to_idp: - sync = _sync_member_to_idp( - user_sub=row.user_sub, - idp_user_id=row.idp_user_id, - username=next_username, + if not next_email: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync") + idp = KeycloakAdminService(get_settings()) + sync_result = idp.ensure_user( + sub=user.user_sub, email=next_email, + username=next_username, display_name=next_display_name, is_active=next_is_active, + idp_user_id=user.idp_user_id, ) - idp_user_id = str(sync["idp_user_id"]) + user.idp_user_id = sync_result.user_id - row = users_repo.upsert_by_sub( - user_sub=row.user_sub, - username=next_username, - email=next_email, - display_name=next_display_name, - is_active=next_is_active, - idp_user_id=idp_user_id, - ) - return MemberItem( - id=row.id, - user_sub=row.user_sub, - username=row.username, - email=row.email, - display_name=row.display_name, - is_active=row.is_active, + updated = users_repo.update_member( + user, + username=payload.username, + email=payload.email, + display_name=payload.display_name, + is_active=payload.is_active, + status=payload.status, ) + return _member_item(updated) @router.delete("/members/{user_sub}") -def delete_member( - user_sub: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: +def delete_member(user_sub: str, db: Session = Depends(get_db), sync_to_idp: bool = Query(default=True)) -> dict[str, str]: users_repo = UsersRepository(db) - row = users_repo.get_by_sub(user_sub) - if not row: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") - settings = get_settings() - service = KeycloakAdminService(settings=settings) - service.delete_user( - idp_user_id=row.idp_user_id, - email=row.email, - username=row.username, - ) - db.execute(delete(PermissionGroupMember).where(PermissionGroupMember.user_sub == user_sub)) - db.delete(row) - db.commit() - return {"deleted": 1, "result": "deleted"} + user = users_repo.get_by_sub(user_sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + + if sync_to_idp: + idp = KeycloakAdminService(get_settings()) + idp.delete_user(idp_user_id=user.idp_user_id, email=user.email, username=user.username) + + users_repo.delete(user) + return {"deleted": user_sub} @router.post("/members/{user_sub}/password/reset", response_model=MemberPasswordResetResponse) -def reset_member_password( - user_sub: str, - db: Session = Depends(get_db), -) -> MemberPasswordResetResponse: +def reset_member_password(user_sub: str, db: Session = Depends(get_db)) -> MemberPasswordResetResponse: users_repo = UsersRepository(db) user = users_repo.get_by_sub(user_sub) if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") - settings = get_settings() - service = KeycloakAdminService(settings=settings) - result = service.reset_password( - idp_user_id=user.idp_user_id, - email=user.email, - username=user.username, - ) - user = users_repo.upsert_by_sub( - user_sub=user.user_sub, - username=user.username, - email=user.email, - display_name=user.display_name, - is_active=user.is_active, - idp_user_id=result.user_id, - ) - return MemberPasswordResetResponse(user_sub=user.user_sub, temporary_password=result.temporary_password) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + + idp = KeycloakAdminService(get_settings()) + result = idp.reset_password(idp_user_id=user.idp_user_id, email=user.email, username=user.username) + if user.idp_user_id != result.user_id: + user.idp_user_id = result.user_id + db.commit() + + return MemberPasswordResetResponse(user_sub=user_sub, temporary_password=result.temporary_password) -@router.get("/members/{user_sub}/permission-groups", response_model=MemberPermissionGroupsResponse) -def get_member_permission_groups( - user_sub: str, - db: Session = Depends(get_db), -) -> MemberPermissionGroupsResponse: +@router.get("/sites/{site_key}/members", response_model=SiteMembersResponse) +def list_site_members(site_key: str, db: Session = Depends(get_db)) -> SiteMembersResponse: + sites_repo = SitesRepository(db) + user_sites_repo = UserSitesRepository(db) + + site = sites_repo.get_by_key(site_key) + if not site: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") + + rows = user_sites_repo.list_site_member_rows(site.id) + return SiteMembersResponse(site_key=site_key, members=[_member_item(user) for _, user in rows]) + + +@router.get("/members/{user_sub}/sites", response_model=UserSitesResponse) +def list_member_sites(user_sub: str, db: Session = Depends(get_db)) -> UserSitesResponse: users_repo = UsersRepository(db) - groups_repo = PermissionGroupsRepository(db) + user_sites_repo = UserSitesRepository(db) + user = users_repo.get_by_sub(user_sub) if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") - group_keys = groups_repo.list_group_keys_by_member_sub(user_sub) - return MemberPermissionGroupsResponse(user_sub=user_sub, group_keys=group_keys) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + + rows = user_sites_repo.list_user_site_rows(user.id) + items = [ + UserSiteItem( + id=user_site.id, + site_key=site.site_key, + site_display_name=site.display_name, + company_key=company.company_key, + company_display_name=company.display_name, + ) + for user_site, site, company in rows + ] + return UserSitesResponse(user_sub=user_sub, sites=items) -@router.put("/members/{user_sub}/permission-groups", response_model=MemberPermissionGroupsResponse) -def set_member_permission_groups( - user_sub: str, - payload: MemberPermissionGroupsUpdateRequest, - db: Session = Depends(get_db), -) -> MemberPermissionGroupsResponse: +@router.put("/members/{user_sub}/sites", response_model=UserSitesResponse) +def set_member_sites(user_sub: str, payload: UserSiteAssignRequest, db: Session = Depends(get_db)) -> UserSitesResponse: users_repo = UsersRepository(db) - groups_repo = PermissionGroupsRepository(db) + sites_repo = SitesRepository(db) + user_sites_repo = UserSitesRepository(db) + user = users_repo.get_by_sub(user_sub) if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - unique_group_keys = list(dict.fromkeys(payload.group_keys)) - groups = groups_repo.get_by_keys(unique_group_keys) - found_keys = {g.group_key for g in groups} - missing = [k for k in unique_group_keys if k not in found_keys] - if missing: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"group_not_found:{','.join(missing)}") + site_ids: list[str] = [] + for site_key in list(dict.fromkeys(payload.site_keys)): + site = sites_repo.get_by_key(site_key) + if not site: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{site_key}") + site_ids.append(site.id) - groups_repo.replace_member_groups(user_sub, [g.id for g in groups]) - return MemberPermissionGroupsResponse(user_sub=user_sub, group_keys=unique_group_keys) + user_sites_repo.set_user_sites(user_id=user.id, site_ids=site_ids) + return list_member_sites(user_sub=user_sub, db=db) -@router.get("/api-clients") +@router.get("/members/{user_sub}/roles", response_model=UserEffectiveRolesResponse) +def list_member_effective_roles(user_sub: str, db: Session = Depends(get_db)) -> UserEffectiveRolesResponse: + users_repo = UsersRepository(db) + user_sites_repo = UserSitesRepository(db) + + user = users_repo.get_by_sub(user_sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + + rows = user_sites_repo.get_user_role_rows(user.id) + items = [ + UserEffectiveRoleItem( + site_key=site.site_key, + site_display_name=site.display_name, + company_key=company.company_key, + company_display_name=company.display_name, + system_key=system.system_key, + system_name=system.name, + role_key=role.role_key, + role_name=role.name, + idp_role_name=role.idp_role_name, + ) + for site, company, role, system in rows + ] + return UserEffectiveRolesResponse(user_sub=user_sub, roles=items) + + +@router.get("/api-clients", response_model=ListResponse) def list_api_clients( db: Session = Depends(get_db), keyword: str | None = Query(default=None), - limit: int = Query(default=200, ge=1, le=500), + status_filter: str | None = Query(default=None, alias="status"), + limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), -) -> dict: - stmt = select(ApiClient) - count_stmt = select(ApiClient) - if keyword: - pattern = f"%{keyword}%" - filter_cond = (ApiClient.client_key.ilike(pattern)) | (ApiClient.name.ilike(pattern)) - stmt = stmt.where(filter_cond) - count_stmt = count_stmt.where(filter_cond) - items = list(db.scalars(stmt.order_by(ApiClient.created_at.desc()).limit(limit).offset(offset)).all()) - total = len(list(db.scalars(count_stmt))) - return { - "items": [_serialize_api_client(item).model_dump() for item in items], - "total": total, - "limit": limit, - "offset": offset, - } +) -> ListResponse: + repo = ApiClientsRepository(db) + items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) + return ListResponse( + items=[ApiClientItem.model_validate(i, from_attributes=True) for i in items], + total=total, + limit=limit, + offset=offset, + ) @router.post("/api-clients", response_model=ApiClientCreateResponse) -def create_api_client( - payload: ApiClientCreateRequest, - db: Session = Depends(get_db), -) -> ApiClientCreateResponse: - status_value = payload.status.strip().lower() - if status_value not in {"active", "inactive"}: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_status") - client_key = payload.client_key or _generate_unique_key( - "AC", lambda value: db.scalar(select(ApiClient).where(ApiClient.client_key == value)) is not None - ) - exists = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) - if exists: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="client_key_already_exists") - api_key = _generate_api_key() - row = ApiClient( +def create_api_client(payload: ApiClientCreateRequest, db: Session = Depends(get_db)) -> ApiClientCreateResponse: + repo = ApiClientsRepository(db) + + client_key = payload.client_key or _generate_unique_key("AK", lambda key: repo.get_by_key(key) is not None) + if repo.get_by_key(client_key): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="client_key_exists") + + plain_key = secrets.token_urlsafe(32) + api_key_hash = hash_api_key(plain_key) + + item = repo.create( client_key=client_key, name=payload.name, - status=status_value, - api_key_hash=hash_api_key(api_key), + status=payload.status, + api_key_hash=api_key_hash, allowed_origins=payload.allowed_origins, allowed_ips=payload.allowed_ips, allowed_paths=payload.allowed_paths, rate_limit_per_min=payload.rate_limit_per_min, expires_at=payload.expires_at, ) - db.add(row) - db.commit() - db.refresh(row) - return ApiClientCreateResponse(item=_serialize_api_client(row), api_key=api_key) + return ApiClientCreateResponse(item=ApiClientItem.model_validate(item, from_attributes=True), api_key=plain_key) @router.patch("/api-clients/{client_key}", response_model=ApiClientItem) -def update_api_client( - client_key: str, - payload: ApiClientUpdateRequest, - db: Session = Depends(get_db), -) -> ApiClientItem: - row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) - if not row: +def update_api_client(client_key: str, payload: ApiClientUpdateRequest, db: Session = Depends(get_db)) -> ApiClientItem: + repo = ApiClientsRepository(db) + item = repo.get_by_key(client_key) + if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found") - if payload.name is not None: - row.name = payload.name - if payload.status is not None: - next_status = payload.status.strip().lower() - if next_status not in {"active", "inactive"}: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_status") - row.status = next_status - if payload.allowed_origins is not None: - row.allowed_origins = payload.allowed_origins - if payload.allowed_ips is not None: - row.allowed_ips = payload.allowed_ips - if payload.allowed_paths is not None: - row.allowed_paths = payload.allowed_paths - row.rate_limit_per_min = payload.rate_limit_per_min - row.expires_at = payload.expires_at - db.commit() - db.refresh(row) - return _serialize_api_client(row) + repo.update( + item, + name=payload.name, + status=payload.status, + allowed_origins=payload.allowed_origins, + allowed_ips=payload.allowed_ips, + allowed_paths=payload.allowed_paths, + rate_limit_per_min=payload.rate_limit_per_min, + expires_at=payload.expires_at, + ) + return ApiClientItem.model_validate(item, from_attributes=True) @router.post("/api-clients/{client_key}/rotate-key", response_model=ApiClientRotateKeyResponse) -def rotate_api_client_key( - client_key: str, - db: Session = Depends(get_db), -) -> ApiClientRotateKeyResponse: - row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) - if not row: +def rotate_api_client_key(client_key: str, db: Session = Depends(get_db)) -> ApiClientRotateKeyResponse: + repo = ApiClientsRepository(db) + item = repo.get_by_key(client_key) + if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found") - api_key = _generate_api_key() - row.api_key_hash = hash_api_key(api_key) - db.commit() - return ApiClientRotateKeyResponse(client_key=row.client_key, api_key=api_key) + + plain_key = secrets.token_urlsafe(32) + repo.update(item, api_key_hash=hash_api_key(plain_key)) + return ApiClientRotateKeyResponse(client_key=client_key, api_key=plain_key) @router.delete("/api-clients/{client_key}") -def delete_api_client( - client_key: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - row = db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) - if not row: +def delete_api_client(client_key: str, db: Session = Depends(get_db)) -> dict[str, str]: + repo = ApiClientsRepository(db) + item = repo.get_by_key(client_key) + if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found") - db.delete(row) - db.commit() - return {"deleted": 1, "result": "deleted"} - - -@router.get("/permission-groups") -def list_permission_groups( - db: Session = Depends(get_db), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> dict: - repo = PermissionGroupsRepository(db) - items, total = repo.list(limit=limit, offset=offset) - return {"items": [PermissionGroupItem(id=i.id, group_key=i.group_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset} - - -@router.get("/permission-groups/{group_key}/permissions") -def list_permission_group_permissions( - group_key: str, - db: Session = Depends(get_db), -) -> dict[str, list[dict]]: - repo = PermissionGroupsRepository(db) - group = repo.get_by_key(group_key) - if not group: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - rows = repo.list_group_permissions(group.id) - return { - "items": [ - PermissionGroupPermissionItem( - id=r.id, - system=r.system, - module="" if r.module == "__system__" else r.module, - action=r.action, - scope_type=r.scope_type, - scope_id=r.scope_id, - ).model_dump() - for r in rows - if r.action in {"view", "edit"} and r.scope_type == "site" - ] - } - - -@router.get("/permission-groups/{group_key}/bindings", response_model=GroupBindingSnapshot) -def get_permission_group_bindings( - group_key: str, - db: Session = Depends(get_db), -) -> GroupBindingSnapshot: - repo = PermissionGroupsRepository(db) - group = repo.get_by_key(group_key) - if not group: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - snapshot = repo.get_group_binding_snapshot(group.id, group_key) - return GroupBindingSnapshot( - group_key=snapshot["group_key"], - site_keys=snapshot["site_keys"], - system_keys=snapshot["system_keys"], - module_keys=[k.split("|", 1)[1] if "|" in k else k for k in snapshot["module_keys"]], - member_subs=snapshot["member_subs"], - actions=snapshot["actions"], - ) - - -@router.put("/permission-groups/{group_key}/bindings", response_model=GroupBindingSnapshot) -def replace_permission_group_bindings( - group_key: str, - payload: GroupBindingUpdateRequest, - db: Session = Depends(get_db), -) -> GroupBindingSnapshot: - repo = PermissionGroupsRepository(db) - sites_repo = SitesRepository(db) - systems_repo = SystemsRepository(db) - modules_repo = ModulesRepository(db) - - group = repo.get_by_key(group_key) - if not group: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - - site_keys = list(dict.fromkeys(payload.site_keys)) - system_keys = list(dict.fromkeys(payload.system_keys)) - module_keys = list(dict.fromkeys(payload.module_keys)) - - valid_sites = {s.site_key for s in sites_repo.list(limit=10000, offset=0)[0]} - missing_sites = [k for k in site_keys if k not in valid_sites] - if missing_sites: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{','.join(missing_sites)}") - - valid_systems = {s.system_key for s in systems_repo.list(limit=10000, offset=0)[0]} - missing_systems = [k for k in system_keys if k not in valid_systems] - if missing_systems: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"system_not_found:{','.join(missing_systems)}") - - all_modules = modules_repo.list(limit=10000, offset=0)[0] - valid_modules = {m.module_key for m in all_modules} - module_system_lookup = {m.module_key: m.system_key for m in all_modules} - missing_modules = [k for k in module_keys if k not in valid_modules] - if missing_modules: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"module_not_found:{','.join(missing_modules)}") - - module_pairs = [f"{module_system_lookup[m]}|{m}" for m in module_keys] - - repo.replace_group_bindings( - group_id=group.id, - site_keys=site_keys, - system_keys=system_keys, - module_keys=module_pairs, - member_subs=payload.member_subs, - actions=payload.actions, - ) - snapshot = repo.get_group_binding_snapshot(group.id, group_key) - return GroupBindingSnapshot( - group_key=snapshot["group_key"], - site_keys=snapshot["site_keys"], - system_keys=snapshot["system_keys"], - module_keys=[k.split("|", 1)[1] if "|" in k else k for k in snapshot["module_keys"]], - member_subs=snapshot["member_subs"], - actions=snapshot["actions"], - ) - - -@router.post("/permission-groups", response_model=PermissionGroupItem) -def create_permission_group( - payload: PermissionGroupCreateRequest, - db: Session = Depends(get_db), -) -> PermissionGroupItem: - repo = PermissionGroupsRepository(db) - group_key = _generate_unique_key("GP", repo.get_by_key) - row = repo.create(group_key=group_key, name=payload.name, status=payload.status) - return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status) - - -@router.patch("/permission-groups/{group_key}", response_model=PermissionGroupItem) -def update_permission_group( - group_key: str, - payload: PermissionGroupUpdateRequest, - db: Session = Depends(get_db), -) -> PermissionGroupItem: - repo = PermissionGroupsRepository(db) - row = repo.get_by_key(group_key) - if not row: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - row = repo.update(row, name=payload.name, status=payload.status) - return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status) - - -@router.delete("/permission-groups/{group_key}") -def delete_permission_group( - group_key: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - repo = PermissionGroupsRepository(db) - row = repo.get_by_key(group_key) - if not row: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - db.delete(row) - db.commit() - return {"deleted": 1, "result": "deleted"} - - -@router.post("/permission-groups/{group_key}/members/{user_sub}") -def add_group_member( - group_key: str, - user_sub: str, - db: Session = Depends(get_db), -) -> dict[str, str]: - groups_repo = PermissionGroupsRepository(db) - group = groups_repo.get_by_key(group_key) - if not group: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - row = groups_repo.add_member_if_not_exists(group.id, user_sub) - return {"membership_id": row.id, "result": "added"} - - -@router.delete("/permission-groups/{group_key}/members/{user_sub}") -def remove_group_member( - group_key: str, - user_sub: str, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - groups_repo = PermissionGroupsRepository(db) - group = groups_repo.get_by_key(group_key) - if not group: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - deleted = groups_repo.remove_member(group.id, user_sub) - return {"deleted": deleted, "result": "removed"} - - -@router.post("/permission-groups/{group_key}/permissions/grant") -def grant_group_permission( - group_key: str, - payload: PermissionGrantRequest, - db: Session = Depends(get_db), -) -> dict[str, str]: - groups_repo = PermissionGroupsRepository(db) - group = groups_repo.get_by_key(group_key) - if not group: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - _resolve_module_id(db, payload.system, payload.module) - _resolve_scope_ids(db, payload.scope_type, payload.scope_id) - module_name = _split_module_key(payload.module) - row = groups_repo.grant_group_permission( - group_id=group.id, - system=payload.system, - module=module_name, - action=payload.action, - scope_type=payload.scope_type, - scope_id=payload.scope_id, - ) - return {"permission_id": row.id, "result": "granted"} - - -@router.post("/permission-groups/{group_key}/permissions/revoke") -def revoke_group_permission( - group_key: str, - payload: PermissionRevokeRequest, - db: Session = Depends(get_db), -) -> dict[str, int | str]: - groups_repo = PermissionGroupsRepository(db) - group = groups_repo.get_by_key(group_key) - if not group: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") - _resolve_module_id(db, payload.system, payload.module) - _resolve_scope_ids(db, payload.scope_type, payload.scope_id) - module_name = _split_module_key(payload.module) - deleted = groups_repo.revoke_group_permission( - group_id=group.id, - system=payload.system, - module=module_name, - action=payload.action, - scope_type=payload.scope_type, - scope_id=payload.scope_id, - ) - return {"deleted": deleted, "result": "revoked"} + repo.delete(item) + return {"deleted": client_key} diff --git a/backend/app/api/internal.py b/backend/app/api/internal.py index 1e2dbca..01b733d 100644 --- a/backend/app/api/internal.py +++ b/backend/app/api/internal.py @@ -3,11 +3,11 @@ from sqlalchemy.orm import Session from app.core.config import get_settings from app.db.session import get_db -from app.repositories.permissions_repo import PermissionsRepository -from app.schemas.internal import InternalUpsertUserBySubResponse from app.repositories.users_repo import UsersRepository +from app.repositories.user_sites_repo import UserSitesRepository from app.schemas.idp_admin import KeycloakEnsureUserRequest, KeycloakEnsureUserResponse -from app.schemas.permissions import PermissionSnapshotResponse +from app.schemas.internal import InternalUpsertUserBySubResponse, InternalUserRoleItem, InternalUserRoleResponse +from app.schemas.permissions import RoleSnapshotResponse from app.schemas.users import UserUpsertBySubRequest from app.security.api_client_auth import require_api_client from app.services.idp_admin_service import KeycloakAdminService @@ -28,32 +28,84 @@ def upsert_user_by_sub( email=payload.email, display_name=payload.display_name, is_active=payload.is_active, + status=payload.status, + ) + return InternalUpsertUserBySubResponse( + id=user.id, + user_sub=user.user_sub, + idp_user_id=user.idp_user_id, + username=user.username, + email=user.email, + display_name=user.display_name, + is_active=user.is_active, + status=user.status, ) - return { - "id": user.id, - "user_sub": user.user_sub, - "idp_user_id": user.idp_user_id, - "username": user.username, - "email": user.email, - "display_name": user.display_name, - "is_active": user.is_active, - } -@router.get("/permissions/{user_sub}/snapshot", response_model=PermissionSnapshotResponse) -def get_permission_snapshot( - user_sub: str, - db: Session = Depends(get_db), -) -> PermissionSnapshotResponse: +def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, str, str, str, str, str, str, str]]: users_repo = UsersRepository(db) - perms_repo = PermissionsRepository(db) + user_sites_repo = UserSitesRepository(db) user = users_repo.get_by_sub(user_sub) if user is None: - return PermissionSnapshotResponse(user_sub=user_sub, permissions=[]) + return [] - permissions = perms_repo.list_by_user(user.id, user.user_sub) - return PermissionService.build_snapshot(user_sub=user_sub, permissions=permissions) + rows = user_sites_repo.get_user_role_rows(user.id) + return [ + ( + site.site_key, + site.display_name, + company.company_key, + company.display_name, + system.system_key, + system.name, + role.role_key, + role.name, + role.idp_role_name, + ) + for site, company, role, system in rows + ] + + +@router.get("/users/{user_sub}/roles", response_model=InternalUserRoleResponse) +def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUserRoleResponse: + rows = _build_user_role_rows(db, user_sub) + return InternalUserRoleResponse( + user_sub=user_sub, + roles=[ + InternalUserRoleItem( + site_key=site_key, + site_display_name=site_display_name, + company_key=company_key, + company_display_name=company_display_name, + system_key=system_key, + system_name=system_name, + role_key=role_key, + role_name=role_name, + idp_role_name=idp_role_name, + ) + for ( + site_key, + site_display_name, + company_key, + company_display_name, + system_key, + system_name, + role_key, + role_name, + idp_role_name, + ) in rows + ], + ) + + +@router.get("/permissions/{user_sub}/snapshot", response_model=RoleSnapshotResponse) +def get_permission_snapshot( + user_sub: str, + db: Session = Depends(get_db), +) -> RoleSnapshotResponse: + rows = _build_user_role_rows(db, user_sub) + return PermissionService.build_role_snapshot(user_sub=user_sub, rows=rows) @router.post("/idp/users/ensure", response_model=KeycloakEnsureUserResponse) @@ -73,17 +125,17 @@ def ensure_idp_user( ) users_repo = UsersRepository(db) - resolved_sub = payload.user_sub or "" - if sync_result.user_sub: - resolved_sub = sync_result.user_sub + resolved_sub = payload.user_sub or sync_result.user_sub or "" if not resolved_sub: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_sub") + users_repo.upsert_by_sub( user_sub=resolved_sub, username=payload.username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, + status="active", idp_user_id=sync_result.user_id, ) return KeycloakEnsureUserResponse(idp_user_id=sync_result.user_id, action=sync_result.action) diff --git a/backend/app/api/internal_catalog.py b/backend/app/api/internal_catalog.py index ad048ff..ab7fa49 100644 --- a/backend/app/api/internal_catalog.py +++ b/backend/app/api/internal_catalog.py @@ -3,14 +3,15 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.repositories.companies_repo import CompaniesRepository -from app.repositories.modules_repo import ModulesRepository +from app.repositories.roles_repo import RolesRepository from app.repositories.sites_repo import SitesRepository from app.repositories.systems_repo import SystemsRepository from app.repositories.users_repo import UsersRepository from app.schemas.internal import ( InternalCompanyListResponse, InternalMemberListResponse, - InternalModuleListResponse, + InternalRoleItem, + InternalRoleListResponse, InternalSiteListResponse, InternalSystemListResponse, ) @@ -27,24 +28,13 @@ def internal_list_systems( ) -> InternalSystemListResponse: repo = SystemsRepository(db) items, total = repo.list(limit=limit, offset=offset) - return {"items": [{"id": i.id, "system_key": i.system_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset} - - -@router.get("/modules", response_model=InternalModuleListResponse) -def internal_list_modules( - db: Session = Depends(get_db), - limit: int = Query(default=500, ge=1, le=2000), - offset: int = Query(default=0, ge=0), -) -> InternalModuleListResponse: - modules_repo = ModulesRepository(db) - items, total = modules_repo.list(limit=limit, offset=offset) return { "items": [ { "id": i.id, - "module_key": i.module_key, "system_key": i.system_key, "name": i.name, + "idp_client_id": i.idp_client_id, "status": i.status, } for i in items @@ -55,6 +45,43 @@ def internal_list_modules( } +@router.get("/roles", response_model=InternalRoleListResponse) +def internal_list_roles( + db: Session = Depends(get_db), + system_key: str | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=2000), + offset: int = Query(default=0, ge=0), +) -> InternalRoleListResponse: + systems_repo = SystemsRepository(db) + roles_repo = RolesRepository(db) + + system_id = None + systems, _ = systems_repo.list(limit=5000, offset=0) + system_map = {s.id: s for s in systems} + if system_key: + system = systems_repo.get_by_key(system_key) + if not system: + return InternalRoleListResponse(items=[], total=0, limit=limit, offset=offset) + system_id = system.id + + items, total = roles_repo.list(system_id=system_id, limit=limit, offset=offset) + rows = [ + InternalRoleItem( + id=i.id, + role_key=i.role_key, + system_key=system_map[i.system_id].system_key, + system_name=system_map[i.system_id].name, + name=i.name, + idp_role_name=i.idp_role_name, + description=i.description, + status=i.status, + ) + for i in items + if i.system_id in system_map + ] + return InternalRoleListResponse(items=rows, total=total, limit=limit, offset=offset) + + @router.get("/companies", response_model=InternalCompanyListResponse) def internal_list_companies( db: Session = Depends(get_db), @@ -64,7 +91,21 @@ def internal_list_companies( ) -> InternalCompanyListResponse: repo = CompaniesRepository(db) items, total = repo.list(keyword=keyword, limit=limit, offset=offset) - return {"items": [{"id": i.id, "company_key": i.company_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset} + return { + "items": [ + { + "id": i.id, + "company_key": i.company_key, + "display_name": i.display_name, + "legal_name": i.legal_name, + "status": i.status, + } + for i in items + ], + "total": total, + "limit": limit, + "offset": offset, + } @router.get("/sites", response_model=InternalSiteListResponse) @@ -81,10 +122,27 @@ def internal_list_sites( company = companies_repo.get_by_key(company_key) if company: company_id = company.id - companies, _ = companies_repo.list(limit=2000, offset=0) - mapping = {c.id: c.company_key for c in companies} + companies, _ = companies_repo.list(limit=5000, offset=0) + mapping = {c.id: c for c in companies} items, total = sites_repo.list(company_id=company_id, limit=limit, offset=offset) - return {"items": [{"id": i.id, "site_key": i.site_key, "company_key": mapping.get(i.company_id), "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset} + return { + "items": [ + { + "id": i.id, + "site_key": i.site_key, + "company_key": mapping[i.company_id].company_key, + "company_display_name": mapping[i.company_id].display_name, + "display_name": i.display_name, + "domain": i.domain, + "status": i.status, + } + for i in items + if i.company_id in mapping + ], + "total": total, + "limit": limit, + "offset": offset, + } @router.get("/members", response_model=InternalMemberListResponse) @@ -105,6 +163,7 @@ def internal_list_members( "email": i.email, "display_name": i.display_name, "is_active": i.is_active, + "status": i.status, } for i in items ], diff --git a/backend/app/api/me.py b/backend/app/api/me.py index 5a0937e..5739704 100644 --- a/backend/app/api/me.py +++ b/backend/app/api/me.py @@ -3,10 +3,10 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.db.session import get_db -from app.repositories.permissions_repo import PermissionsRepository from app.repositories.users_repo import UsersRepository +from app.repositories.user_sites_repo import UserSitesRepository from app.schemas.auth import KeycloakPrincipal, MeSummaryResponse -from app.schemas.permissions import PermissionSnapshotResponse +from app.schemas.permissions import RoleSnapshotResponse from app.security.idp_jwt import require_authenticated_principal from app.services.permission_service import PermissionService @@ -26,10 +26,10 @@ def get_me( email=principal.email, display_name=principal.name or principal.preferred_username, is_active=True, + status="active", ) return MeSummaryResponse(sub=user.user_sub, email=user.email, display_name=user.display_name) except SQLAlchemyError: - # DB schema compatibility fallback for local bring-up. return MeSummaryResponse( sub=principal.sub, email=principal.email, @@ -37,14 +37,14 @@ def get_me( ) -@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse) +@router.get("/permissions/snapshot", response_model=RoleSnapshotResponse) def get_my_permission_snapshot( principal: KeycloakPrincipal = Depends(require_authenticated_principal), db: Session = Depends(get_db), -) -> PermissionSnapshotResponse: +) -> RoleSnapshotResponse: try: users_repo = UsersRepository(db) - perms_repo = PermissionsRepository(db) + user_sites_repo = UserSitesRepository(db) user = users_repo.upsert_by_sub( user_sub=principal.sub, @@ -52,8 +52,23 @@ def get_my_permission_snapshot( email=principal.email, display_name=principal.name or principal.preferred_username, is_active=True, + status="active", ) - permissions = perms_repo.list_by_user(user.id, user.user_sub) - return PermissionService.build_snapshot(user_sub=principal.sub, permissions=permissions) + rows = user_sites_repo.get_user_role_rows(user.id) + serialized = [ + ( + site.site_key, + site.display_name, + company.company_key, + company.display_name, + system.system_key, + system.name, + role.role_key, + role.name, + role.idp_role_name, + ) + for site, company, role, system in rows + ] + return PermissionService.build_role_snapshot(user_sub=principal.sub, rows=serialized) except SQLAlchemyError: - return PermissionSnapshotResponse(user_sub=principal.sub, permissions=[]) + return RoleSnapshotResponse(user_sub=principal.sub, roles=[]) diff --git a/backend/app/main.py b/backend/app/main.py index 48d4624..4ae1a2c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.admin import router as admin_router from app.api.admin_catalog import router as admin_catalog_router from app.api.auth import router as auth_router from app.api.internal_catalog import router as internal_catalog_router @@ -28,7 +27,6 @@ def healthz() -> dict[str, str]: app.include_router(internal_router) app.include_router(internal_catalog_router) -app.include_router(admin_router) app.include_router(admin_catalog_router) app.include_router(me_router) app.include_router(auth_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 235653c..4f0777f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,25 +1,21 @@ from app.models.api_client import ApiClient +from app.models.auth_sync_state import AuthSyncState from app.models.company import Company -from app.models.module import Module -from app.models.permission import Permission -from app.models.permission_group import PermissionGroup -from app.models.permission_group_member import PermissionGroupMember -from app.models.permission_group_permission import PermissionGroupPermission +from app.models.role import Role from app.models.site import Site +from app.models.site_role import SiteRole from app.models.system import System from app.models.user import User -from app.models.user_scope_permission import UserScopePermission +from app.models.user_site import UserSite __all__ = [ "ApiClient", + "AuthSyncState", "Company", - "Module", - "Permission", - "PermissionGroup", - "PermissionGroupMember", - "PermissionGroupPermission", + "Role", "Site", + "SiteRole", "System", "User", - "UserScopePermission", + "UserSite", ] diff --git a/backend/app/models/auth_sync_state.py b/backend/app/models/auth_sync_state.py new file mode 100644 index 0000000..0174eb7 --- /dev/null +++ b/backend/app/models/auth_sync_state.py @@ -0,0 +1,21 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AuthSyncState(Base): + __tablename__ = "auth_sync_state" + __table_args__ = (UniqueConstraint("entity_type", "entity_id", name="uq_auth_sync_state_entity"),) + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) + entity_type: Mapped[str] = mapped_column(String(32), nullable=False) + entity_id: Mapped[str] = mapped_column(UUID(as_uuid=False), nullable=False) + last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + source_version: Mapped[str | None] = mapped_column(String(255)) + last_error: Mapped[str | None] = mapped_column(String) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/app/models/company.py b/backend/app/models/company.py index f0081b3..764fb31 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -13,7 +13,9 @@ class Company(Base): id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) company_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) - name: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + legal_name: Mapped[str | None] = mapped_column(String(255)) + idp_group_id: Mapped[str | None] = mapped_column(String(128)) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/models/permission.py b/backend/app/models/permission.py deleted file mode 100644 index d19af29..0000000 --- a/backend/app/models/permission.py +++ /dev/null @@ -1,31 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class Permission(Base): - __tablename__ = "permissions" - __table_args__ = ( - UniqueConstraint( - "user_id", - "scope_type", - "scope_id", - "module", - "action", - name="uq_permissions_user_scope_module_action", - ), - ) - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - scope_type: Mapped[str] = mapped_column(String(32), nullable=False) - scope_id: Mapped[str] = mapped_column(String(128), nullable=False) - module: Mapped[str] = mapped_column(String(128), nullable=False) - action: Mapped[str] = mapped_column(String(32), nullable=False) - - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/app/models/permission_group_member.py b/backend/app/models/permission_group_member.py deleted file mode 100644 index 9f67480..0000000 --- a/backend/app/models/permission_group_member.py +++ /dev/null @@ -1,20 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class PermissionGroupMember(Base): - __tablename__ = "permission_group_members" - __table_args__ = (UniqueConstraint("group_id", "user_sub", name="uq_permission_group_members_group_sub"),) - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - group_id: Mapped[str] = mapped_column( - UUID(as_uuid=False), ForeignKey("permission_groups.id", ondelete="CASCADE"), nullable=False - ) - user_sub: Mapped[str] = mapped_column(String(255), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/app/models/permission_group_permission.py b/backend/app/models/permission_group_permission.py deleted file mode 100644 index 0d819e0..0000000 --- a/backend/app/models/permission_group_permission.py +++ /dev/null @@ -1,23 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, String, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class PermissionGroupPermission(Base): - __tablename__ = "permission_group_permissions" - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - group_id: Mapped[str] = mapped_column( - UUID(as_uuid=False), ForeignKey("permission_groups.id", ondelete="CASCADE"), nullable=False - ) - system: Mapped[str] = mapped_column(String(64), nullable=False) - module: Mapped[str] = mapped_column(String(128), nullable=False) - action: Mapped[str] = mapped_column(String(32), nullable=False) - scope_type: Mapped[str] = mapped_column(String(16), nullable=False) - scope_id: Mapped[str] = mapped_column(String(128), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/app/models/module.py b/backend/app/models/role.py similarity index 54% rename from backend/app/models/module.py rename to backend/app/models/role.py index d1ffe29..0bcbe4e 100644 --- a/backend/app/models/module.py +++ b/backend/app/models/role.py @@ -1,22 +1,23 @@ from datetime import datetime from uuid import uuid4 -from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base -class Module(Base): - __tablename__ = "modules" +class Role(Base): + __tablename__ = "roles" + __table_args__ = (UniqueConstraint("system_id", "idp_role_name", name="uq_roles_system_idp_role_name"),) id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - system_key: Mapped[str] = mapped_column( - String(128), ForeignKey("systems.system_key", ondelete="CASCADE"), nullable=False, index=True - ) - module_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) + role_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) + system_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("systems.id", ondelete="CASCADE"), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(String(1024)) + idp_role_name: Mapped[str] = mapped_column(String(255), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/models/site.py b/backend/app/models/site.py index eafdd2d..94f1aff 100644 --- a/backend/app/models/site.py +++ b/backend/app/models/site.py @@ -14,7 +14,9 @@ class Site(Base): id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) site_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) company_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False) - name: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + domain: Mapped[str | None] = mapped_column(String(255)) + idp_group_id: Mapped[str | None] = mapped_column(String(128)) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/models/site_role.py b/backend/app/models/site_role.py new file mode 100644 index 0000000..de37137 --- /dev/null +++ b/backend/app/models/site_role.py @@ -0,0 +1,18 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SiteRole(Base): + __tablename__ = "site_roles" + __table_args__ = (UniqueConstraint("site_id", "role_id", name="uq_site_roles_site_role"),) + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) + site_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"), nullable=False) + role_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("roles.id", ondelete="CASCADE"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/app/models/system.py b/backend/app/models/system.py index c5ee6ae..7700f5d 100644 --- a/backend/app/models/system.py +++ b/backend/app/models/system.py @@ -14,6 +14,7 @@ class System(Base): id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) system_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False) + idp_client_id: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e08260a..1336994 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,12 +13,12 @@ class User(Base): id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) user_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) - idp_user_id: Mapped[str | None] = mapped_column(String(128)) + idp_user_id: Mapped[str | None] = mapped_column(String(128), unique=True) username: Mapped[str | None] = mapped_column(String(255), unique=True) - email: Mapped[str | None] = mapped_column(String(320)) + email: Mapped[str | None] = mapped_column(String(320), unique=True) display_name: Mapped[str | None] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False diff --git a/backend/app/models/user_scope_permission.py b/backend/app/models/user_scope_permission.py deleted file mode 100644 index 08f243f..0000000 --- a/backend/app/models/user_scope_permission.py +++ /dev/null @@ -1,24 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, String, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class UserScopePermission(Base): - __tablename__ = "user_scope_permissions" - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - module_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("modules.id", ondelete="CASCADE"), nullable=False) - action: Mapped[str] = mapped_column(String(32), nullable=False) - scope_type: Mapped[str] = mapped_column(String(16), nullable=False) - company_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE")) - site_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE")) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False - ) diff --git a/backend/app/models/permission_group.py b/backend/app/models/user_site.py similarity index 54% rename from backend/app/models/permission_group.py rename to backend/app/models/user_site.py index f7f5a5c..5d29797 100644 --- a/backend/app/models/permission_group.py +++ b/backend/app/models/user_site.py @@ -1,20 +1,20 @@ from datetime import datetime from uuid import uuid4 -from sqlalchemy import DateTime, String, func +from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base -class PermissionGroup(Base): - __tablename__ = "permission_groups" +class UserSite(Base): + __tablename__ = "user_sites" + __table_args__ = (UniqueConstraint("user_id", "site_id", name="uq_user_sites_user_site"),) id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - group_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) - name: Mapped[str] = mapped_column(String(255), nullable=False) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + site_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False diff --git a/backend/app/repositories/api_clients_repo.py b/backend/app/repositories/api_clients_repo.py new file mode 100644 index 0000000..1d19daa --- /dev/null +++ b/backend/app/repositories/api_clients_repo.py @@ -0,0 +1,94 @@ +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.models.api_client import ApiClient + + +class ApiClientsRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_by_key(self, client_key: str) -> ApiClient | None: + return self.db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) + + def list(self, *, keyword: str | None = None, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[ApiClient], int]: + stmt = select(ApiClient) + count_stmt = select(func.count()).select_from(ApiClient) + if keyword: + pattern = f"%{keyword}%" + cond = or_(ApiClient.client_key.ilike(pattern), ApiClient.name.ilike(pattern)) + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) + if status: + stmt = stmt.where(ApiClient.status == status) + count_stmt = count_stmt.where(ApiClient.status == status) + + stmt = stmt.order_by(ApiClient.created_at.desc()).limit(limit).offset(offset) + return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) + + def create( + self, + *, + client_key: str, + name: str, + status: str, + api_key_hash: str, + allowed_origins: list[str], + allowed_ips: list[str], + allowed_paths: list[str], + rate_limit_per_min: int | None, + expires_at, + ) -> ApiClient: + item = ApiClient( + client_key=client_key, + name=name, + status=status, + api_key_hash=api_key_hash, + allowed_origins=allowed_origins, + allowed_ips=allowed_ips, + allowed_paths=allowed_paths, + rate_limit_per_min=rate_limit_per_min, + expires_at=expires_at, + ) + self.db.add(item) + self.db.commit() + self.db.refresh(item) + return item + + def update( + self, + item: ApiClient, + *, + name: str | None = None, + status: str | None = None, + api_key_hash: str | None = None, + allowed_origins: list[str] | None = None, + allowed_ips: list[str] | None = None, + allowed_paths: list[str] | None = None, + rate_limit_per_min: int | None = None, + expires_at=None, + ) -> ApiClient: + if name is not None: + item.name = name + if status is not None: + item.status = status + if api_key_hash is not None: + item.api_key_hash = api_key_hash + if allowed_origins is not None: + item.allowed_origins = allowed_origins + if allowed_ips is not None: + item.allowed_ips = allowed_ips + if allowed_paths is not None: + item.allowed_paths = allowed_paths + if rate_limit_per_min is not None: + item.rate_limit_per_min = rate_limit_per_min + if expires_at is not None: + item.expires_at = expires_at + + self.db.commit() + self.db.refresh(item) + return item + + def delete(self, item: ApiClient) -> None: + self.db.delete(item) + self.db.commit() diff --git a/backend/app/repositories/companies_repo.py b/backend/app/repositories/companies_repo.py index 184ff21..bbe8979 100644 --- a/backend/app/repositories/companies_repo.py +++ b/backend/app/repositories/companies_repo.py @@ -9,36 +9,55 @@ class CompaniesRepository: self.db = db def get_by_key(self, company_key: str) -> Company | None: - stmt = select(Company).where(Company.company_key == company_key) - return self.db.scalar(stmt) + return self.db.scalar(select(Company).where(Company.company_key == company_key)) def get_by_id(self, company_id: str) -> Company | None: - stmt = select(Company).where(Company.id == company_id) - return self.db.scalar(stmt) + return self.db.scalar(select(Company).where(Company.id == company_id)) def list(self, keyword: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[Company], int]: stmt = select(Company) count_stmt = select(func.count()).select_from(Company) if keyword: pattern = f"%{keyword}%" - cond = or_(Company.company_key.ilike(pattern), Company.name.ilike(pattern)) + cond = or_( + Company.company_key.ilike(pattern), + Company.display_name.ilike(pattern), + Company.legal_name.ilike(pattern), + ) stmt = stmt.where(cond) count_stmt = count_stmt.where(cond) + stmt = stmt.order_by(Company.created_at.desc()).limit(limit).offset(offset) return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - def create(self, company_key: str, name: str, status: str = "active") -> Company: - item = Company(company_key=company_key, name=name, status=status) + def create(self, *, company_key: str, display_name: str, legal_name: str | None, status: str = "active") -> Company: + item = Company(company_key=company_key, display_name=display_name, legal_name=legal_name, status=status) self.db.add(item) self.db.commit() self.db.refresh(item) return item - def update(self, item: Company, *, name: str | None = None, status: str | None = None) -> Company: - if name is not None: - item.name = name + def update( + self, + item: Company, + *, + display_name: str | None = None, + legal_name: str | None = None, + idp_group_id: str | None = None, + status: str | None = None, + ) -> Company: + if display_name is not None: + item.display_name = display_name + if legal_name is not None: + item.legal_name = legal_name + if idp_group_id is not None: + item.idp_group_id = idp_group_id if status is not None: item.status = status self.db.commit() self.db.refresh(item) return item + + def delete(self, item: Company) -> None: + self.db.delete(item) + self.db.commit() diff --git a/backend/app/repositories/modules_repo.py b/backend/app/repositories/modules_repo.py deleted file mode 100644 index 2dfb165..0000000 --- a/backend/app/repositories/modules_repo.py +++ /dev/null @@ -1,35 +0,0 @@ -from sqlalchemy import func, select -from sqlalchemy.orm import Session - -from app.models.module import Module - - -class ModulesRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def get_by_key(self, module_key: str) -> Module | None: - stmt = select(Module).where(Module.module_key == module_key) - return self.db.scalar(stmt) - - def list(self, limit: int = 200, offset: int = 0) -> tuple[list[Module], int]: - stmt = select(Module) - count_stmt = select(func.count()).select_from(Module) - stmt = stmt.order_by(Module.created_at.desc()).limit(limit).offset(offset) - return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - - def create(self, module_key: str, system_key: str, name: str, status: str = "active") -> Module: - item = Module(module_key=module_key, system_key=system_key, name=name, status=status) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def update(self, item: Module, *, name: str | None = None, status: str | None = None) -> Module: - if name is not None: - item.name = name - if status is not None: - item.status = status - self.db.commit() - self.db.refresh(item) - return item diff --git a/backend/app/repositories/permission_groups_repo.py b/backend/app/repositories/permission_groups_repo.py deleted file mode 100644 index d8d4164..0000000 --- a/backend/app/repositories/permission_groups_repo.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations - -from sqlalchemy import delete, func, select -from sqlalchemy.orm import Session - -from app.models.permission_group import PermissionGroup -from app.models.permission_group_member import PermissionGroupMember -from app.models.permission_group_permission import PermissionGroupPermission -from app.models.user import User - - -class PermissionGroupsRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def get_by_key(self, group_key: str) -> PermissionGroup | None: - return self.db.scalar(select(PermissionGroup).where(PermissionGroup.group_key == group_key)) - - def get_by_id(self, group_id: str) -> PermissionGroup | None: - return self.db.scalar(select(PermissionGroup).where(PermissionGroup.id == group_id)) - - def get_by_keys(self, group_keys: list[str]) -> list[PermissionGroup]: - if not group_keys: - return [] - stmt = select(PermissionGroup).where(PermissionGroup.group_key.in_(group_keys)) - return list(self.db.scalars(stmt).all()) - - def list(self, limit: int = 100, offset: int = 0) -> tuple[list[PermissionGroup], int]: - stmt = select(PermissionGroup).order_by(PermissionGroup.created_at.desc()).limit(limit).offset(offset) - count_stmt = select(func.count()).select_from(PermissionGroup) - return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - - def create(self, group_key: str, name: str, status: str = "active") -> PermissionGroup: - item = PermissionGroup(group_key=group_key, name=name, status=status) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def update(self, item: PermissionGroup, *, name: str | None = None, status: str | None = None) -> PermissionGroup: - if name is not None: - item.name = name - if status is not None: - item.status = status - self.db.commit() - self.db.refresh(item) - return item - - def add_member_if_not_exists(self, group_id: str, user_sub: str) -> PermissionGroupMember: - existing = self.db.scalar( - select(PermissionGroupMember).where( - PermissionGroupMember.group_id == group_id, PermissionGroupMember.user_sub == user_sub - ) - ) - if existing: - return existing - row = PermissionGroupMember(group_id=group_id, user_sub=user_sub) - self.db.add(row) - self.db.commit() - self.db.refresh(row) - return row - - def remove_member(self, group_id: str, user_sub: str) -> int: - result = self.db.execute( - delete(PermissionGroupMember).where( - PermissionGroupMember.group_id == group_id, PermissionGroupMember.user_sub == user_sub - ) - ) - self.db.commit() - return int(result.rowcount or 0) - - def list_group_keys_by_member_sub(self, user_sub: str) -> list[str]: - stmt = ( - select(PermissionGroup.group_key) - .select_from(PermissionGroupMember) - .join(PermissionGroup, PermissionGroup.id == PermissionGroupMember.group_id) - .where(PermissionGroupMember.user_sub == user_sub) - .order_by(PermissionGroup.group_key.asc()) - ) - return [row[0] for row in self.db.execute(stmt).all()] - - def replace_member_groups(self, user_sub: str, group_ids: list[str]) -> None: - self.db.execute(delete(PermissionGroupMember).where(PermissionGroupMember.user_sub == user_sub)) - for group_id in group_ids: - self.db.add(PermissionGroupMember(group_id=group_id, user_sub=user_sub)) - self.db.commit() - - def grant_group_permission( - self, - group_id: str, - system: str, - module: str, - action: str, - scope_type: str, - scope_id: str, - ) -> PermissionGroupPermission: - where = [ - PermissionGroupPermission.group_id == group_id, - PermissionGroupPermission.system == system, - PermissionGroupPermission.module == module, - PermissionGroupPermission.action == action, - PermissionGroupPermission.scope_type == scope_type, - PermissionGroupPermission.scope_id == scope_id, - ] - existing = self.db.scalar(select(PermissionGroupPermission).where(*where)) - if existing: - return existing - row = PermissionGroupPermission( - group_id=group_id, - system=system, - module=module, - action=action, - scope_type=scope_type, - scope_id=scope_id, - ) - self.db.add(row) - self.db.commit() - self.db.refresh(row) - return row - - def list_group_permissions(self, group_id: str) -> list[PermissionGroupPermission]: - stmt = ( - select(PermissionGroupPermission) - .where(PermissionGroupPermission.group_id == group_id) - .order_by(PermissionGroupPermission.scope_type.asc(), PermissionGroupPermission.scope_id.asc(), PermissionGroupPermission.system.asc(), PermissionGroupPermission.module.asc(), PermissionGroupPermission.action.asc()) - ) - return list(self.db.scalars(stmt).all()) - - def replace_group_bindings( - self, - *, - group_id: str, - site_keys: list[str], - system_keys: list[str], - module_keys: list[str], - member_subs: list[str], - actions: list[str], - ) -> None: - normalized_sites = list(dict.fromkeys([s for s in site_keys if s])) - normalized_actions = [a for a in list(dict.fromkeys(actions)) if a in {"view", "edit"}] - normalized_member_subs = list(dict.fromkeys([s for s in member_subs if s])) - - normalized_systems = set([s for s in system_keys if s]) - module_pairs = [] - for pair in module_keys: - if "|" not in pair: - continue - system_key, module_key = pair.split("|", 1) - if not system_key or not module_key: - continue - module_pairs.append((system_key, module_key)) - normalized_systems.add(system_key) - - self.db.execute(delete(PermissionGroupPermission).where(PermissionGroupPermission.group_id == group_id)) - self.db.execute(delete(PermissionGroupMember).where(PermissionGroupMember.group_id == group_id)) - - for sub in normalized_member_subs: - self.db.add(PermissionGroupMember(group_id=group_id, user_sub=sub)) - - for site_key in normalized_sites: - for action in normalized_actions: - for system_key in sorted(normalized_systems): - module_names = [m for s, m in module_pairs if s == system_key] or ["__system__"] - for module_name in module_names: - self.db.add( - PermissionGroupPermission( - group_id=group_id, - system=system_key, - module=module_name, - action=action, - scope_type="site", - scope_id=site_key, - ) - ) - - self.db.commit() - - def get_group_binding_snapshot(self, group_id: str, group_key: str) -> dict: - permissions = self.list_group_permissions(group_id) - site_keys = sorted({p.scope_id for p in permissions if p.scope_type == "site"}) - system_keys = sorted({p.system for p in permissions}) - actions = sorted({p.action for p in permissions if p.action in {"view", "edit"}}) - module_keys = sorted( - { - f"{p.system}|{p.module}" - for p in permissions - if p.module and p.module != "__system__" - } - ) - member_subs = sorted(self.list_group_member_subs(group_id)) - return { - "group_key": group_key, - "site_keys": site_keys, - "system_keys": system_keys, - "module_keys": module_keys, - "member_subs": member_subs, - "actions": actions, - } - - def list_group_member_subs(self, group_id: str) -> list[str]: - stmt = ( - select(PermissionGroupMember.user_sub) - .where(PermissionGroupMember.group_id == group_id) - .order_by(PermissionGroupMember.user_sub.asc()) - ) - return [row[0] for row in self.db.execute(stmt).all()] - - def list_system_groups(self, system_key: str) -> list[PermissionGroup]: - stmt = ( - select(PermissionGroup) - .join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroup.id) - .where(PermissionGroupPermission.system == system_key) - .order_by(PermissionGroup.name.asc()) - .distinct() - ) - return list(self.db.scalars(stmt).all()) - - def list_system_members(self, system_key: str) -> list[User]: - stmt = ( - select(User) - .join(PermissionGroupMember, PermissionGroupMember.user_sub == User.user_sub) - .join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroupMember.group_id) - .where(PermissionGroupPermission.system == system_key) - .order_by(User.email.asc(), User.user_sub.asc()) - .distinct() - ) - return list(self.db.scalars(stmt).all()) - - def list_module_groups(self, system_key: str, module_name: str) -> list[PermissionGroup]: - stmt = ( - select(PermissionGroup) - .join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroup.id) - .where(PermissionGroupPermission.system == system_key, PermissionGroupPermission.module == module_name) - .order_by(PermissionGroup.name.asc()) - .distinct() - ) - return list(self.db.scalars(stmt).all()) - - def list_module_members(self, system_key: str, module_name: str) -> list[User]: - stmt = ( - select(User) - .join(PermissionGroupMember, PermissionGroupMember.user_sub == User.user_sub) - .join(PermissionGroupPermission, PermissionGroupPermission.group_id == PermissionGroupMember.group_id) - .where(PermissionGroupPermission.system == system_key, PermissionGroupPermission.module == module_name) - .order_by(User.email.asc(), User.user_sub.asc()) - .distinct() - ) - return list(self.db.scalars(stmt).all()) - - def revoke_group_permission( - self, - group_id: str, - system: str, - module: str, - action: str, - scope_type: str, - scope_id: str, - ) -> int: - stmt = delete(PermissionGroupPermission).where( - PermissionGroupPermission.group_id == group_id, - PermissionGroupPermission.system == system, - PermissionGroupPermission.module == module, - PermissionGroupPermission.action == action, - PermissionGroupPermission.scope_type == scope_type, - PermissionGroupPermission.scope_id == scope_id, - ) - result = self.db.execute(stmt) - self.db.commit() - return int(result.rowcount or 0) diff --git a/backend/app/repositories/permissions_repo.py b/backend/app/repositories/permissions_repo.py deleted file mode 100644 index 78b1941..0000000 --- a/backend/app/repositories/permissions_repo.py +++ /dev/null @@ -1,231 +0,0 @@ -from sqlalchemy import and_, delete, func, literal, or_, select -from sqlalchemy.orm import Session - -from app.models.company import Company -from app.models.module import Module -from app.models.permission_group_member import PermissionGroupMember -from app.models.permission_group_permission import PermissionGroupPermission -from app.models.site import Site -from app.models.user import User -from app.models.user_scope_permission import UserScopePermission - - -class PermissionsRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def list_by_user(self, user_id: str, user_sub: str) -> list[tuple[str, str, str | None, str, str]]: - direct_stmt = ( - select( - literal("direct"), - UserScopePermission.scope_type, - Company.company_key, - Site.site_key, - Module.system_key, - Module.module_key, - UserScopePermission.action, - ) - .select_from(UserScopePermission) - .join(Module, Module.id == UserScopePermission.module_id) - .join(Company, Company.id == UserScopePermission.company_id, isouter=True) - .join(Site, Site.id == UserScopePermission.site_id, isouter=True) - .where(UserScopePermission.user_id == user_id) - .where(UserScopePermission.action.in_(["view", "edit"])) - .where(UserScopePermission.scope_type == "site") - ) - group_stmt = ( - select( - literal("group"), - PermissionGroupPermission.scope_type, - PermissionGroupPermission.scope_id, - PermissionGroupPermission.system, - PermissionGroupPermission.module, - PermissionGroupPermission.action, - ) - .select_from(PermissionGroupPermission) - .join(PermissionGroupMember, PermissionGroupMember.group_id == PermissionGroupPermission.group_id) - .where(PermissionGroupMember.user_sub == user_sub) - .where(PermissionGroupPermission.action.in_(["view", "edit"])) - .where(PermissionGroupPermission.scope_type == "site") - ) - rows = self.db.execute(direct_stmt).all() + self.db.execute(group_stmt).all() - result: list[tuple[str, str, str | None, str, str]] = [] - dedup = set() - for row in rows: - source = row[0] - if source == "group": - _, scope_type, scope_id, system_key, module_key, action = row - if module_key == "__system__": - module_key = f"__system__{system_key}" - else: - _, scope_type, company_key, site_key, system_key, module_key, action = row - scope_id = company_key if scope_type == "company" else site_key - key = (scope_type, scope_id or "", system_key, module_key, action) - if key in dedup: - continue - dedup.add(key) - result.append(key) - return result - - def create_if_not_exists( - self, - user_id: str, - module_id: str, - action: str, - scope_type: str, - company_id: str | None, - site_id: str | None, - ) -> UserScopePermission: - where_expr = [ - UserScopePermission.user_id == user_id, - UserScopePermission.module_id == module_id, - UserScopePermission.action == action, - UserScopePermission.scope_type == scope_type, - ] - if scope_type == "company": - where_expr.append(UserScopePermission.company_id == company_id) - else: - where_expr.append(UserScopePermission.site_id == site_id) - - existing = self.db.scalar(select(UserScopePermission).where(and_(*where_expr))) - if existing: - return existing - - item = UserScopePermission( - user_id=user_id, - module_id=module_id, - action=action, - scope_type=scope_type, - company_id=company_id, - site_id=site_id, - ) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def revoke( - self, - user_id: str, - module_id: str, - action: str, - scope_type: str, - company_id: str | None, - site_id: str | None, - ) -> int: - stmt = delete(UserScopePermission).where( - UserScopePermission.user_id == user_id, - UserScopePermission.module_id == module_id, - UserScopePermission.action == action, - UserScopePermission.scope_type == scope_type, - or_( - and_(scope_type == "company", UserScopePermission.company_id == company_id), - and_(scope_type == "site", UserScopePermission.site_id == site_id), - ), - ) - result = self.db.execute(stmt) - self.db.commit() - return int(result.rowcount or 0) - - def list_direct_permissions( - self, - *, - keyword: str | None = None, - scope_type: str | None = None, - limit: int = 200, - offset: int = 0, - ) -> tuple[list[dict], int]: - stmt = ( - select( - UserScopePermission.id, - User.user_sub, - User.email, - User.display_name, - UserScopePermission.scope_type, - Company.company_key, - Site.site_key, - Module.system_key, - Module.module_key, - UserScopePermission.action, - UserScopePermission.created_at, - ) - .select_from(UserScopePermission) - .join(User, User.id == UserScopePermission.user_id) - .join(Module, Module.id == UserScopePermission.module_id) - .join(Company, Company.id == UserScopePermission.company_id, isouter=True) - .join(Site, Site.id == UserScopePermission.site_id, isouter=True) - .where(UserScopePermission.action.in_(["view", "edit"])) - .where(UserScopePermission.scope_type == "site") - ) - count_stmt = ( - select(func.count()) - .select_from(UserScopePermission) - .join(User, User.id == UserScopePermission.user_id) - .join(Module, Module.id == UserScopePermission.module_id) - .join(Company, Company.id == UserScopePermission.company_id, isouter=True) - .join(Site, Site.id == UserScopePermission.site_id, isouter=True) - .where(UserScopePermission.action.in_(["view", "edit"])) - .where(UserScopePermission.scope_type == "site") - ) - - if scope_type == "site": - stmt = stmt.where(UserScopePermission.scope_type == scope_type) - count_stmt = count_stmt.where(UserScopePermission.scope_type == scope_type) - - if keyword: - pattern = f"%{keyword}%" - cond = or_( - User.user_sub.ilike(pattern), - User.email.ilike(pattern), - User.display_name.ilike(pattern), - Module.module_key.ilike(pattern), - Company.company_key.ilike(pattern), - Site.site_key.ilike(pattern), - UserScopePermission.action.ilike(pattern), - ) - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - - stmt = stmt.order_by(UserScopePermission.created_at.desc()).limit(limit).offset(offset) - rows = self.db.execute(stmt).all() - total = int(self.db.scalar(count_stmt) or 0) - items: list[dict] = [] - for row in rows: - ( - permission_id, - user_sub, - email, - display_name, - row_scope_type, - company_key, - site_key, - system_key, - module_key, - action, - created_at, - ) = row - scope_id = company_key if row_scope_type == "company" else site_key - module_name = module_key - if isinstance(module_name, str) and module_name.startswith("__system__"): - module_name = None - items.append( - { - "permission_id": permission_id, - "user_sub": user_sub, - "email": email, - "display_name": display_name, - "scope_type": row_scope_type, - "scope_id": scope_id, - "system": system_key, - "module": module_name, - "action": action, - "created_at": created_at, - } - ) - return items, total - - def revoke_by_permission_id(self, permission_id: str) -> int: - stmt = delete(UserScopePermission).where(UserScopePermission.id == permission_id) - result = self.db.execute(stmt) - self.db.commit() - return int(result.rowcount or 0) diff --git a/backend/app/repositories/roles_repo.py b/backend/app/repositories/roles_repo.py new file mode 100644 index 0000000..fc2d566 --- /dev/null +++ b/backend/app/repositories/roles_repo.py @@ -0,0 +1,97 @@ +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.models.role import Role + + +class RolesRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_by_key(self, role_key: str) -> Role | None: + return self.db.scalar(select(Role).where(Role.role_key == role_key)) + + def get_by_id(self, role_id: str) -> Role | None: + return self.db.scalar(select(Role).where(Role.id == role_id)) + + def list( + self, + *, + keyword: str | None = None, + system_id: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[Role], int]: + stmt = select(Role) + count_stmt = select(func.count()).select_from(Role) + if keyword: + pattern = f"%{keyword}%" + cond = or_( + Role.role_key.ilike(pattern), + Role.name.ilike(pattern), + Role.idp_role_name.ilike(pattern), + Role.description.ilike(pattern), + ) + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) + if system_id: + stmt = stmt.where(Role.system_id == system_id) + count_stmt = count_stmt.where(Role.system_id == system_id) + if status: + stmt = stmt.where(Role.status == status) + count_stmt = count_stmt.where(Role.status == status) + + stmt = stmt.order_by(Role.created_at.desc()).limit(limit).offset(offset) + return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) + + def create( + self, + *, + role_key: str, + system_id: str, + name: str, + description: str | None, + idp_role_name: str, + status: str = "active", + ) -> Role: + item = Role( + role_key=role_key, + system_id=system_id, + name=name, + description=description, + idp_role_name=idp_role_name, + status=status, + ) + self.db.add(item) + self.db.commit() + self.db.refresh(item) + return item + + def update( + self, + item: Role, + *, + system_id: str | None = None, + name: str | None = None, + description: str | None = None, + idp_role_name: str | None = None, + status: str | None = None, + ) -> Role: + if system_id is not None: + item.system_id = system_id + if name is not None: + item.name = name + if description is not None: + item.description = description + if idp_role_name is not None: + item.idp_role_name = idp_role_name + if status is not None: + item.status = status + self.db.commit() + self.db.refresh(item) + return item + + def delete(self, item: Role) -> None: + self.db.delete(item) + self.db.commit() diff --git a/backend/app/repositories/site_roles_repo.py b/backend/app/repositories/site_roles_repo.py new file mode 100644 index 0000000..4771bdd --- /dev/null +++ b/backend/app/repositories/site_roles_repo.py @@ -0,0 +1,37 @@ +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.models.role import Role +from app.models.site import Site +from app.models.site_role import SiteRole +from app.models.system import System + + +class SiteRolesRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list_site_role_rows(self, site_id: str) -> list[tuple[SiteRole, Role, System]]: + stmt = ( + select(SiteRole, Role, System) + .join(Role, Role.id == SiteRole.role_id) + .join(System, System.id == Role.system_id) + .where(SiteRole.site_id == site_id) + .order_by(System.name.asc(), Role.name.asc()) + ) + return list(self.db.execute(stmt).all()) + + def list_role_site_rows(self, role_id: str) -> list[tuple[SiteRole, Site]]: + stmt = ( + select(SiteRole, Site) + .join(Site, Site.id == SiteRole.site_id) + .where(SiteRole.role_id == role_id) + .order_by(Site.display_name.asc()) + ) + return list(self.db.execute(stmt).all()) + + def set_site_roles(self, *, site_id: str, role_ids: list[str]) -> None: + self.db.execute(delete(SiteRole).where(SiteRole.site_id == site_id)) + for role_id in role_ids: + self.db.add(SiteRole(site_id=site_id, role_id=role_id)) + self.db.commit() diff --git a/backend/app/repositories/sites_repo.py b/backend/app/repositories/sites_repo.py index f17cc5b..679647e 100644 --- a/backend/app/repositories/sites_repo.py +++ b/backend/app/repositories/sites_repo.py @@ -9,11 +9,14 @@ class SitesRepository: self.db = db def get_by_key(self, site_key: str) -> Site | None: - stmt = select(Site).where(Site.site_key == site_key) - return self.db.scalar(stmt) + return self.db.scalar(select(Site).where(Site.site_key == site_key)) + + def get_by_id(self, site_id: str) -> Site | None: + return self.db.scalar(select(Site).where(Site.id == site_id)) def list( self, + *, keyword: str | None = None, company_id: str | None = None, limit: int = 100, @@ -21,19 +24,30 @@ class SitesRepository: ) -> tuple[list[Site], int]: stmt = select(Site) count_stmt = select(func.count()).select_from(Site) + if keyword: pattern = f"%{keyword}%" - cond = or_(Site.site_key.ilike(pattern), Site.name.ilike(pattern)) + cond = or_(Site.site_key.ilike(pattern), Site.display_name.ilike(pattern), Site.domain.ilike(pattern)) stmt = stmt.where(cond) count_stmt = count_stmt.where(cond) + if company_id: stmt = stmt.where(Site.company_id == company_id) count_stmt = count_stmt.where(Site.company_id == company_id) + stmt = stmt.order_by(Site.created_at.desc()).limit(limit).offset(offset) return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - def create(self, site_key: str, company_id: str, name: str, status: str = "active") -> Site: - item = Site(site_key=site_key, company_id=company_id, name=name, status=status) + def create( + self, + *, + site_key: str, + company_id: str, + display_name: str, + domain: str | None, + status: str = "active", + ) -> Site: + item = Site(site_key=site_key, company_id=company_id, display_name=display_name, domain=domain, status=status) self.db.add(item) self.db.commit() self.db.refresh(item) @@ -44,15 +58,25 @@ class SitesRepository: item: Site, *, company_id: str | None = None, - name: str | None = None, + display_name: str | None = None, + domain: str | None = None, + idp_group_id: str | None = None, status: str | None = None, ) -> Site: if company_id is not None: item.company_id = company_id - if name is not None: - item.name = name + if display_name is not None: + item.display_name = display_name + if domain is not None: + item.domain = domain + if idp_group_id is not None: + item.idp_group_id = idp_group_id if status is not None: item.status = status self.db.commit() self.db.refresh(item) return item + + def delete(self, item: Site) -> None: + self.db.delete(item) + self.db.commit() diff --git a/backend/app/repositories/systems_repo.py b/backend/app/repositories/systems_repo.py index d265822..c76cdaa 100644 --- a/backend/app/repositories/systems_repo.py +++ b/backend/app/repositories/systems_repo.py @@ -1,4 +1,4 @@ -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.models.system import System @@ -9,34 +9,51 @@ class SystemsRepository: self.db = db def get_by_key(self, system_key: str) -> System | None: - stmt = select(System).where(System.system_key == system_key) - return self.db.scalar(stmt) + return self.db.scalar(select(System).where(System.system_key == system_key)) def get_by_id(self, system_id: str) -> System | None: - stmt = select(System).where(System.id == system_id) - return self.db.scalar(stmt) + return self.db.scalar(select(System).where(System.id == system_id)) - def list(self, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[System], int]: + def list(self, *, keyword: str | None = None, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[System], int]: stmt = select(System) count_stmt = select(func.count()).select_from(System) + if keyword: + pattern = f"%{keyword}%" + cond = or_(System.system_key.ilike(pattern), System.name.ilike(pattern), System.idp_client_id.ilike(pattern)) + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) if status: stmt = stmt.where(System.status == status) count_stmt = count_stmt.where(System.status == status) + stmt = stmt.order_by(System.created_at.desc()).limit(limit).offset(offset) return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - def create(self, system_key: str, name: str, status: str = "active") -> System: - item = System(system_key=system_key, name=name, status=status) + def create(self, *, system_key: str, name: str, idp_client_id: str, status: str = "active") -> System: + item = System(system_key=system_key, name=name, idp_client_id=idp_client_id, status=status) self.db.add(item) self.db.commit() self.db.refresh(item) return item - def update(self, item: System, *, name: str | None = None, status: str | None = None) -> System: + def update( + self, + item: System, + *, + name: str | None = None, + idp_client_id: str | None = None, + status: str | None = None, + ) -> System: if name is not None: item.name = name + if idp_client_id is not None: + item.idp_client_id = idp_client_id if status is not None: item.status = status self.db.commit() self.db.refresh(item) return item + + def delete(self, item: System) -> None: + self.db.delete(item) + self.db.commit() diff --git a/backend/app/repositories/user_sites_repo.py b/backend/app/repositories/user_sites_repo.py new file mode 100644 index 0000000..255650d --- /dev/null +++ b/backend/app/repositories/user_sites_repo.py @@ -0,0 +1,54 @@ +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.models.company import Company +from app.models.role import Role +from app.models.site import Site +from app.models.site_role import SiteRole +from app.models.system import System +from app.models.user import User +from app.models.user_site import UserSite + + +class UserSitesRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list_user_site_rows(self, user_id: str) -> list[tuple[UserSite, Site, Company]]: + stmt = ( + select(UserSite, Site, Company) + .join(Site, Site.id == UserSite.site_id) + .join(Company, Company.id == Site.company_id) + .where(UserSite.user_id == user_id) + .order_by(Company.display_name.asc(), Site.display_name.asc()) + ) + return list(self.db.execute(stmt).all()) + + def list_site_member_rows(self, site_id: str) -> list[tuple[UserSite, User]]: + stmt = ( + select(UserSite, User) + .join(User, User.id == UserSite.user_id) + .where(UserSite.site_id == site_id) + .order_by(User.display_name.asc().nulls_last(), User.username.asc().nulls_last(), User.user_sub.asc()) + ) + return list(self.db.execute(stmt).all()) + + def set_user_sites(self, *, user_id: str, site_ids: list[str]) -> None: + self.db.execute(delete(UserSite).where(UserSite.user_id == user_id)) + for site_id in site_ids: + self.db.add(UserSite(user_id=user_id, site_id=site_id)) + self.db.commit() + + def get_user_role_rows(self, user_id: str) -> list[tuple[Site, Company, Role, System]]: + stmt = ( + select(Site, Company, Role, System) + .select_from(UserSite) + .join(Site, Site.id == UserSite.site_id) + .join(Company, Company.id == Site.company_id) + .join(SiteRole, SiteRole.site_id == Site.id) + .join(Role, Role.id == SiteRole.role_id) + .join(System, System.id == Role.system_id) + .where(UserSite.user_id == user_id) + .order_by(Company.display_name.asc(), Site.display_name.asc(), System.name.asc(), Role.name.asc()) + ) + return list(self.db.execute(stmt).all()) diff --git a/backend/app/repositories/users_repo.py b/backend/app/repositories/users_repo.py index 991692d..0801055 100644 --- a/backend/app/repositories/users_repo.py +++ b/backend/app/repositories/users_repo.py @@ -9,15 +9,14 @@ class UsersRepository: self.db = db def get_by_sub(self, user_sub: str) -> User | None: - stmt = select(User).where(User.user_sub == user_sub) - return self.db.scalar(stmt) + return self.db.scalar(select(User).where(User.user_sub == user_sub)) def get_by_id(self, user_id: str) -> User | None: - stmt = select(User).where(User.id == user_id) - return self.db.scalar(stmt) + return self.db.scalar(select(User).where(User.id == user_id)) def list( self, + *, keyword: str | None = None, is_active: bool | None = None, limit: int = 50, @@ -48,11 +47,13 @@ class UsersRepository: def upsert_by_sub( self, + *, user_sub: str, username: str | None, email: str | None, display_name: str | None, is_active: bool, + status: str = "active", idp_user_id: str | None = None, ) -> User: user = self.get_by_sub(user_sub) @@ -64,6 +65,7 @@ class UsersRepository: email=email, display_name=display_name, is_active=is_active, + status=status, ) self.db.add(user) else: @@ -73,6 +75,7 @@ class UsersRepository: user.email = email user.display_name = display_name user.is_active = is_active + user.status = status self.db.commit() self.db.refresh(user) @@ -86,6 +89,7 @@ class UsersRepository: email: str | None = None, display_name: str | None = None, is_active: bool | None = None, + status: str | None = None, ) -> User: if username is not None: user.username = username @@ -95,6 +99,13 @@ class UsersRepository: user.display_name = display_name if is_active is not None: user.is_active = is_active + if status is not None: + user.status = status + self.db.commit() self.db.refresh(user) return user + + def delete(self, user: User) -> None: + self.db.delete(user) + self.db.commit() diff --git a/backend/app/schemas/catalog.py b/backend/app/schemas/catalog.py index 92a4f59..f9d8dad 100644 --- a/backend/app/schemas/catalog.py +++ b/backend/app/schemas/catalog.py @@ -1,113 +1,6 @@ -from pydantic import BaseModel -from typing import Literal +from datetime import datetime - -class SystemCreateRequest(BaseModel): - name: str - status: str = "active" - - -class SystemUpdateRequest(BaseModel): - name: str | None = None - status: str | None = None - - -class SystemItem(BaseModel): - id: str - system_key: str - name: str - status: str - - -class ModuleCreateRequest(BaseModel): - system_key: str - name: str - status: str = "active" - - -class ModuleUpdateRequest(BaseModel): - name: str | None = None - status: str | None = None - - -class ModuleItem(BaseModel): - id: str - system_key: str | None = None - module_key: str - name: str - status: str - - -class CompanyCreateRequest(BaseModel): - name: str - status: str = "active" - - -class CompanyUpdateRequest(BaseModel): - name: str | None = None - status: str | None = None - - -class CompanyItem(BaseModel): - id: str - company_key: str - name: str - status: str - - -class SiteCreateRequest(BaseModel): - company_key: str - name: str - status: str = "active" - - -class SiteUpdateRequest(BaseModel): - company_key: str | None = None - name: str | None = None - status: str | None = None - - -class SiteItem(BaseModel): - id: str - site_key: str - company_key: str - name: str - status: str - - -class MemberItem(BaseModel): - id: str - user_sub: str - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool - - -class MemberUpsertRequest(BaseModel): - user_sub: str | None = None - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool = True - sync_to_idp: bool = True - - -class MemberUpdateRequest(BaseModel): - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool | None = None - sync_to_idp: bool = True - - -class MemberPasswordResetResponse(BaseModel): - user_sub: str - temporary_password: str - - -class MemberPermissionGroupsUpdateRequest(BaseModel): - group_keys: list[str] +from pydantic import BaseModel, Field class ListResponse(BaseModel): @@ -117,62 +10,217 @@ class ListResponse(BaseModel): offset: int -class PermissionGroupCreateRequest(BaseModel): - name: str +class CompanyCreateRequest(BaseModel): + display_name: str + legal_name: str | None = None status: str = "active" -class PermissionGroupUpdateRequest(BaseModel): - name: str | None = None +class CompanyUpdateRequest(BaseModel): + display_name: str | None = None + legal_name: str | None = None + idp_group_id: str | None = None status: str | None = None -class PermissionGroupItem(BaseModel): +class CompanyItem(BaseModel): id: str - group_key: str + company_key: str + display_name: str + legal_name: str | None = None + idp_group_id: str | None = None + status: str + + +class SiteCreateRequest(BaseModel): + company_key: str + display_name: str + domain: str | None = None + status: str = "active" + + +class SiteUpdateRequest(BaseModel): + company_key: str | None = None + display_name: str | None = None + domain: str | None = None + idp_group_id: str | None = None + status: str | None = None + + +class SiteItem(BaseModel): + id: str + site_key: str + company_key: str + company_display_name: str + display_name: str + domain: str | None = None + idp_group_id: str | None = None + status: str + + +class SystemCreateRequest(BaseModel): name: str - status: str + idp_client_id: str + status: str = "active" -class PermissionGroupPermissionItem(BaseModel): +class SystemUpdateRequest(BaseModel): + name: str | None = None + idp_client_id: str | None = None + status: str | None = None + + +class SystemItem(BaseModel): id: str - system: str - module: str - action: Literal["view", "edit"] - scope_type: Literal["site"] - scope_id: str - - -class MemberPermissionGroupsResponse(BaseModel): - user_sub: str - group_keys: list[str] - - -class GroupBindingUpdateRequest(BaseModel): - site_keys: list[str] - system_keys: list[str] - module_keys: list[str] - member_subs: list[str] - actions: list[Literal["view", "edit"]] - - -class GroupBindingSnapshot(BaseModel): - group_key: str - site_keys: list[str] - system_keys: list[str] - module_keys: list[str] - member_subs: list[str] - actions: list[Literal["view", "edit"]] - - -class GroupRelationItem(BaseModel): - group_key: str - group_name: str + system_key: str + name: str + idp_client_id: str status: str -class MemberRelationItem(BaseModel): +class RoleCreateRequest(BaseModel): + system_key: str + name: str + idp_role_name: str + description: str | None = None + status: str = "active" + + +class RoleUpdateRequest(BaseModel): + system_key: str | None = None + name: str | None = None + idp_role_name: str | None = None + description: str | None = None + status: str | None = None + + +class RoleItem(BaseModel): + id: str + role_key: str + system_key: str + system_name: str + name: str + idp_role_name: str + description: str | None = None + status: str + + +class MemberItem(BaseModel): + id: str user_sub: str + idp_user_id: str | None = None + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool + status: str + + +class MemberUpsertRequest(BaseModel): + user_sub: str | None = None + username: str | None = None + email: str | None = None + display_name: str | None = None + is_active: bool = True + status: str = "active" + sync_to_idp: bool = True + + +class MemberUpdateRequest(BaseModel): + username: str | None = None + email: str | None = None + display_name: str | None = None + is_active: bool | None = None + status: str | None = None + sync_to_idp: bool = True + + +class MemberPasswordResetResponse(BaseModel): + user_sub: str + temporary_password: str + + +class SiteRoleAssignRequest(BaseModel): + role_keys: list[str] = Field(default_factory=list) + + +class SiteRoleItem(BaseModel): + id: str + role_key: str + role_name: str + system_key: str + system_name: str + + +class UserSiteAssignRequest(BaseModel): + site_keys: list[str] = Field(default_factory=list) + + +class UserSiteItem(BaseModel): + id: str + site_key: str + site_display_name: str + company_key: str + company_display_name: str + + +class UserEffectiveRoleItem(BaseModel): + site_key: str + site_display_name: str + company_key: str + company_display_name: str + system_key: str + system_name: str + role_key: str + role_name: str + idp_role_name: str + + +class UserEffectiveRolesResponse(BaseModel): + user_sub: str + roles: list[UserEffectiveRoleItem] + + +class SiteMembersResponse(BaseModel): + site_key: str + members: list[MemberItem] + + +class SiteRolesResponse(BaseModel): + site_key: str + roles: list[SiteRoleItem] + + +class UserSitesResponse(BaseModel): + user_sub: str + sites: list[UserSiteItem] + + +class CompanySitesResponse(BaseModel): + company_key: str + sites: list[SiteItem] + + +class SystemRolesResponse(BaseModel): + system_key: str + roles: list[RoleItem] + + +class RoleSitesResponse(BaseModel): + role_key: str + sites: list[UserSiteItem] + + +class ApiClientItem(BaseModel): + id: str + client_key: str + name: str + status: str + allowed_origins: list[str] = Field(default_factory=list) + allowed_ips: list[str] = Field(default_factory=list) + allowed_paths: list[str] = Field(default_factory=list) + rate_limit_per_min: int | None = None + expires_at: datetime | None = None + last_used_at: datetime | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/internal.py b/backend/app/schemas/internal.py index d59f679..d244a3f 100644 --- a/backend/app/schemas/internal.py +++ b/backend/app/schemas/internal.py @@ -5,6 +5,7 @@ class InternalSystemItem(BaseModel): id: str system_key: str name: str + idp_client_id: str status: str @@ -15,16 +16,19 @@ class InternalSystemListResponse(BaseModel): offset: int -class InternalModuleItem(BaseModel): +class InternalRoleItem(BaseModel): id: str - module_key: str + role_key: str system_key: str + system_name: str name: str + idp_role_name: str + description: str | None = None status: str -class InternalModuleListResponse(BaseModel): - items: list[InternalModuleItem] +class InternalRoleListResponse(BaseModel): + items: list[InternalRoleItem] total: int limit: int offset: int @@ -33,7 +37,8 @@ class InternalModuleListResponse(BaseModel): class InternalCompanyItem(BaseModel): id: str company_key: str - name: str + display_name: str + legal_name: str | None = None status: str @@ -47,8 +52,10 @@ class InternalCompanyListResponse(BaseModel): class InternalSiteItem(BaseModel): id: str site_key: str - company_key: str | None = None - name: str + company_key: str + company_display_name: str + display_name: str + domain: str | None = None status: str @@ -66,6 +73,7 @@ class InternalMemberItem(BaseModel): email: str | None = None display_name: str | None = None is_active: bool + status: str class InternalMemberListResponse(BaseModel): @@ -83,3 +91,21 @@ class InternalUpsertUserBySubResponse(BaseModel): email: str | None = None display_name: str | None = None is_active: bool + status: str + + +class InternalUserRoleItem(BaseModel): + site_key: str + site_display_name: str + company_key: str + company_display_name: str + system_key: str + system_name: str + role_key: str + role_name: str + idp_role_name: str + + +class InternalUserRoleResponse(BaseModel): + user_sub: str + roles: list[InternalUserRoleItem] diff --git a/backend/app/schemas/permissions.py b/backend/app/schemas/permissions.py index 92b0808..5513423 100644 --- a/backend/app/schemas/permissions.py +++ b/backend/app/schemas/permissions.py @@ -1,60 +1,18 @@ -from datetime import datetime -from typing import Literal - from pydantic import BaseModel -ActionType = Literal["view", "edit"] -ScopeType = Literal["site"] + +class RoleSnapshotItem(BaseModel): + site_key: str + site_display_name: str + company_key: str + company_display_name: str + system_key: str + system_name: str + role_key: str + role_name: str + idp_role_name: str -class PermissionGrantRequest(BaseModel): +class RoleSnapshotResponse(BaseModel): user_sub: str - email: str | None = None - display_name: str | None = None - scope_type: ScopeType - scope_id: str - system: str - module: str | None = None - action: ActionType - - -class PermissionRevokeRequest(BaseModel): - user_sub: str - scope_type: ScopeType - scope_id: str - system: str - module: str | None = None - action: ActionType - - -class PermissionItem(BaseModel): - scope_type: ScopeType - scope_id: str - system: str | None = None - module: str - action: ActionType - - -class PermissionSnapshotResponse(BaseModel): - user_sub: str - permissions: list[PermissionItem] - - -class DirectPermissionRow(BaseModel): - permission_id: str - user_sub: str - email: str | None = None - display_name: str | None = None - scope_type: ScopeType - scope_id: str - system: str | None = None - module: str | None = None - action: ActionType - created_at: datetime - - -class DirectPermissionListResponse(BaseModel): - items: list[DirectPermissionRow] - total: int - limit: int - offset: int + roles: list[RoleSnapshotItem] diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py index bf9cd6d..f1dc272 100644 --- a/backend/app/schemas/users.py +++ b/backend/app/schemas/users.py @@ -7,3 +7,4 @@ class UserUpsertBySubRequest(BaseModel): email: str | None = None display_name: str | None = None is_active: bool = True + status: str = "active" diff --git a/backend/app/services/permission_service.py b/backend/app/services/permission_service.py index f261626..d31ea1b 100644 --- a/backend/app/services/permission_service.py +++ b/backend/app/services/permission_service.py @@ -1,13 +1,33 @@ -from app.schemas.permissions import PermissionItem, PermissionSnapshotResponse +from app.schemas.permissions import RoleSnapshotItem, RoleSnapshotResponse class PermissionService: @staticmethod - def build_snapshot(user_sub: str, permissions: list[tuple[str, str, str | None, str, str]]) -> PermissionSnapshotResponse: - return PermissionSnapshotResponse( + def build_role_snapshot(user_sub: str, rows: list[tuple[str, str, str, str, str, str, str, str, str]]) -> RoleSnapshotResponse: + return RoleSnapshotResponse( user_sub=user_sub, - permissions=[ - PermissionItem(scope_type=s_type, scope_id=s_id, system=system, module=module, action=action) - for s_type, s_id, system, module, action in permissions + roles=[ + RoleSnapshotItem( + site_key=site_key, + site_display_name=site_display_name, + company_key=company_key, + company_display_name=company_display_name, + system_key=system_key, + system_name=system_name, + role_key=role_key, + role_name=role_name, + idp_role_name=idp_role_name, + ) + for ( + site_key, + site_display_name, + company_key, + company_display_name, + system_key, + system_name, + role_key, + role_name, + idp_role_name, + ) in rows ], ) diff --git a/backend/scripts/init_schema.sql b/backend/scripts/init_schema.sql index 5438443..bd2dfdb 100644 --- a/backend/scripts/init_schema.sql +++ b/backend/scripts/init_schema.sql @@ -2,25 +2,29 @@ BEGIN; CREATE EXTENSION IF NOT EXISTS pgcrypto; --- Drop all managed tables to ensure clean rebuild +-- Drop legacy/managed tables for clean rebuild DROP TABLE IF EXISTS auth_sync_state CASCADE; +DROP TABLE IF EXISTS user_sites CASCADE; +DROP TABLE IF EXISTS site_roles CASCADE; +DROP TABLE IF EXISTS roles CASCADE; +DROP TABLE IF EXISTS api_clients CASCADE; +DROP TABLE IF EXISTS sites CASCADE; +DROP TABLE IF EXISTS companies CASCADE; +DROP TABLE IF EXISTS systems CASCADE; +DROP TABLE IF EXISTS users CASCADE; + +-- legacy tables +DROP TABLE IF EXISTS permissions CASCADE; +DROP TABLE IF EXISTS modules CASCADE; DROP TABLE IF EXISTS user_scope_permissions CASCADE; DROP TABLE IF EXISTS permission_group_permissions CASCADE; DROP TABLE IF EXISTS permission_group_members CASCADE; DROP TABLE IF EXISTS permission_groups CASCADE; -DROP TABLE IF EXISTS modules CASCADE; -DROP TABLE IF EXISTS systems CASCADE; -DROP TABLE IF EXISTS sites CASCADE; -DROP TABLE IF EXISTS companies CASCADE; -DROP TABLE IF EXISTS users CASCADE; -DROP TABLE IF EXISTS api_clients CASCADE; --- remove legacy table if present -DROP TABLE IF EXISTS permissions CASCADE; CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_sub TEXT NOT NULL UNIQUE, - idp_user_id VARCHAR(128), + idp_user_id VARCHAR(128) UNIQUE, username TEXT UNIQUE, email TEXT UNIQUE, display_name TEXT, @@ -30,18 +34,12 @@ CREATE TABLE users ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE auth_sync_state ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - last_synced_at TIMESTAMPTZ, - source_version TEXT, - last_error TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - CREATE TABLE companies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), company_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, + display_name TEXT NOT NULL, + legal_name TEXT, + idp_group_id TEXT, status VARCHAR(16) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -51,7 +49,9 @@ CREATE TABLE sites ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), site_key TEXT NOT NULL UNIQUE, company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, - name TEXT NOT NULL, + display_name TEXT NOT NULL, + domain TEXT, + idp_group_id TEXT, status VARCHAR(16) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -61,67 +61,51 @@ CREATE TABLE systems ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), system_key TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + idp_client_id TEXT NOT NULL UNIQUE, status VARCHAR(16) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE modules ( +CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - system_key TEXT NOT NULL REFERENCES systems(system_key) ON DELETE CASCADE, - module_key TEXT NOT NULL UNIQUE, + role_key TEXT NOT NULL UNIQUE, + system_id UUID NOT NULL REFERENCES systems(id) ON DELETE CASCADE, name TEXT NOT NULL, + description TEXT, + idp_role_name TEXT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- direct permission table retained only for compatibility -CREATE TABLE user_scope_permissions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE, - action VARCHAR(32) NOT NULL, - scope_type VARCHAR(16) NOT NULL, - company_id UUID REFERENCES companies(id) ON DELETE CASCADE, - site_id UUID REFERENCES sites(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT user_scope_permissions_scope_check - CHECK (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL), - CONSTRAINT user_scope_permissions_action_check - CHECK (action IN ('view', 'edit')) + CONSTRAINT uq_roles_system_idp_role_name UNIQUE (system_id, idp_role_name) ); -CREATE TABLE permission_groups ( +CREATE TABLE site_roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - group_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - status VARCHAR(16) NOT NULL DEFAULT 'active', + site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + CONSTRAINT uq_site_roles_site_role UNIQUE (site_id, role_id) ); -CREATE TABLE permission_group_members ( +CREATE TABLE user_sites ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE, - user_sub TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, user_sub) + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_user_sites_user_site UNIQUE (user_id, site_id) ); -CREATE TABLE permission_group_permissions ( +CREATE TABLE auth_sync_state ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE, - system TEXT NOT NULL, - module TEXT NOT NULL, - action TEXT NOT NULL, - scope_type TEXT NOT NULL, - scope_id TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT permission_group_permissions_scope_check CHECK (scope_type = 'site'), - CONSTRAINT permission_group_permissions_action_check CHECK (action IN ('view', 'edit')), - CONSTRAINT uq_pgp_group_rule UNIQUE (group_id, system, module, action, scope_type, scope_id) + entity_type VARCHAR(32) NOT NULL, + entity_id UUID NOT NULL, + last_synced_at TIMESTAMPTZ, + source_version TEXT, + last_error TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_auth_sync_state_entity UNIQUE (entity_type, entity_id) ); CREATE TABLE api_clients ( @@ -140,26 +124,18 @@ CREATE TABLE api_clients ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -INSERT INTO systems (system_key, name, status) -VALUES ('member', 'Member Center', 'active') -ON CONFLICT (system_key) DO NOTHING; - CREATE INDEX idx_users_user_sub ON users(user_sub); CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email ON users(email); 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); -CREATE INDEX idx_usp_site_id ON user_scope_permissions(site_id); -CREATE UNIQUE INDEX uq_usp_site - ON user_scope_permissions(user_id, module_id, action, scope_type, site_id); -CREATE INDEX idx_pgm_group_id ON permission_group_members(group_id); -CREATE INDEX idx_pgm_user_sub ON permission_group_members(user_sub); -CREATE INDEX idx_pgp_group_id ON permission_group_permissions(group_id); -CREATE INDEX idx_pgp_scope_site ON permission_group_permissions(scope_id); +CREATE INDEX idx_roles_system_id ON roles(system_id); +CREATE INDEX idx_site_roles_site_id ON site_roles(site_id); +CREATE INDEX idx_site_roles_role_id ON site_roles(role_id); +CREATE INDEX idx_user_sites_user_id ON user_sites(user_id); +CREATE INDEX idx_user_sites_site_id ON user_sites(site_id); +CREATE INDEX idx_auth_sync_entity ON auth_sync_state(entity_type, entity_id); CREATE INDEX idx_api_clients_status ON api_clients(status); CREATE INDEX idx_api_clients_expires_at ON api_clients(expires_at); CREATE INDEX idx_systems_system_key ON systems(system_key); -CREATE INDEX idx_modules_system_key ON modules(system_key); -CREATE INDEX idx_modules_module_key ON modules(module_key); COMMIT; diff --git a/backend/scripts/migrate_add_idp_user_id.sql b/backend/scripts/migrate_add_idp_user_id.sql deleted file mode 100644 index 8e74482..0000000 --- a/backend/scripts/migrate_add_idp_user_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users - ADD COLUMN IF NOT EXISTS idp_user_id VARCHAR(128); diff --git a/backend/scripts/migrate_add_users_username.sql b/backend/scripts/migrate_add_users_username.sql deleted file mode 100644 index ee1c0c1..0000000 --- a/backend/scripts/migrate_add_users_username.sql +++ /dev/null @@ -1,16 +0,0 @@ -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); diff --git a/backend/scripts/migrate_align_company_site_member_system.sql b/backend/scripts/migrate_align_company_site_member_system.sql deleted file mode 100644 index 7789e86..0000000 --- a/backend/scripts/migrate_align_company_site_member_system.sql +++ /dev/null @@ -1,73 +0,0 @@ -BEGIN; - -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'record_status') THEN - CREATE TYPE record_status AS ENUM ('active','inactive'); - END IF; -END -$$; - -CREATE TABLE IF NOT EXISTS systems ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - system_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - status record_status NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -INSERT INTO systems (system_key, name, status) -VALUES ('member', 'Member Center', 'active') -ON CONFLICT (system_key) DO NOTHING; - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scope_type') THEN - CREATE TYPE scope_type AS ENUM ('company','site'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'permission_action') THEN - CREATE TYPE permission_action AS ENUM ('view','create','update','delete','manage'); - END IF; -END -$$; - -CREATE TABLE IF NOT EXISTS permission_groups ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - group_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - status record_status NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS permission_group_members ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE, - user_sub TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, user_sub) -); - -CREATE TABLE IF NOT EXISTS permission_group_permissions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE, - system TEXT NOT NULL, - module TEXT NOT NULL, - action TEXT NOT NULL, - scope_type TEXT NOT NULL, - scope_id TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_systems_system_key ON systems(system_key); -CREATE INDEX IF NOT EXISTS idx_pgm_group_id ON permission_group_members(group_id); -CREATE INDEX IF NOT EXISTS idx_pgm_user_sub ON permission_group_members(user_sub); -CREATE INDEX IF NOT EXISTS idx_pgp_group_id ON permission_group_permissions(group_id); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_pgp_group_rule -ON permission_group_permissions(group_id, system, module, action, scope_type, scope_id); - -COMMIT; diff --git a/backend/scripts/migrate_enum_to_text.sql b/backend/scripts/migrate_enum_to_text.sql deleted file mode 100644 index 32ea04e..0000000 --- a/backend/scripts/migrate_enum_to_text.sql +++ /dev/null @@ -1,27 +0,0 @@ -BEGIN; - --- users / master tables -ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(16) USING status::text; -ALTER TABLE companies ALTER COLUMN status TYPE VARCHAR(16) USING status::text; -ALTER TABLE sites ALTER COLUMN status TYPE VARCHAR(16) USING status::text; -ALTER TABLE systems ALTER COLUMN status TYPE VARCHAR(16) USING status::text; -ALTER TABLE modules ALTER COLUMN status TYPE VARCHAR(16) USING status::text; -ALTER TABLE permission_groups ALTER COLUMN status TYPE VARCHAR(16) USING status::text; - --- api_clients -ALTER TABLE api_clients ALTER COLUMN status TYPE VARCHAR(16) USING status::text; - --- user scoped permissions -ALTER TABLE user_scope_permissions ALTER COLUMN action TYPE VARCHAR(32) USING action::text; -ALTER TABLE user_scope_permissions ALTER COLUMN scope_type TYPE VARCHAR(16) USING scope_type::text; - --- keep check constraint compatible with varchar -ALTER TABLE user_scope_permissions DROP CONSTRAINT IF EXISTS user_scope_permissions_check; -ALTER TABLE user_scope_permissions - ADD CONSTRAINT user_scope_permissions_check - CHECK ( - ((scope_type = 'company' AND company_id IS NOT NULL AND site_id IS NULL) - OR (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL)) - ); - -COMMIT; diff --git a/backend/scripts/migrate_idp_user_id_to_text.sql b/backend/scripts/migrate_idp_user_id_to_text.sql deleted file mode 100644 index 7f625b3..0000000 --- a/backend/scripts/migrate_idp_user_id_to_text.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE users - ALTER COLUMN idp_user_id TYPE VARCHAR(128) - USING CASE - WHEN idp_user_id IS NULL THEN NULL - ELSE idp_user_id::text - END; diff --git a/backend/scripts/migrate_rename_identity_columns.sql b/backend/scripts/migrate_rename_identity_columns.sql deleted file mode 100644 index f44f7a2..0000000 --- a/backend/scripts/migrate_rename_identity_columns.sql +++ /dev/null @@ -1,43 +0,0 @@ -BEGIN; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'idp_sub' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'user_sub' - ) THEN - ALTER TABLE users RENAME COLUMN idp_sub TO user_sub; - END IF; - - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'idp_user_id' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'idp_user_id' - ) THEN - ALTER TABLE users RENAME COLUMN idp_user_id TO idp_user_id; - END IF; - - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'permission_group_members' AND column_name = 'idp_sub' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'permission_group_members' AND column_name = 'user_sub' - ) THEN - ALTER TABLE permission_group_members RENAME COLUMN idp_sub TO user_sub; - END IF; -END -$$; - -ALTER INDEX IF EXISTS idx_users_idp_sub RENAME TO idx_users_user_sub; -ALTER INDEX IF EXISTS idx_pgm_idp_sub RENAME TO idx_pgm_user_sub; - -CREATE INDEX IF NOT EXISTS idx_users_user_sub ON users(user_sub); -CREATE INDEX IF NOT EXISTS idx_pgm_user_sub ON permission_group_members(user_sub); - -COMMIT; diff --git a/docs/BACKEND_TASKPLAN.md b/docs/BACKEND_TASKPLAN.md index 7f5c445..1837427 100644 --- a/docs/BACKEND_TASKPLAN.md +++ b/docs/BACKEND_TASKPLAN.md @@ -1,19 +1,20 @@ # Backend TaskPlan ## 待辦 -- [ ] 重建 schema 與 migration:退場舊表(`modules` 舊權限流程、`permission_groups*`、`user_scope_permissions`),上線 `roles`、`site_roles`、`user_sites`。 -- [ ] 重做 Admin API:`Company/Site/System/Role/User` CRUD 與關聯管理 API。 -- [ ] 重做 Role API:只允許 Site 指派/解除,不提供 user direct role API。 -- [ ] 重做 `/me/permissions/snapshot`:改為 role 聚合格式(由 user_sites + site_roles 推導)。 - [ ] Keycloak 同步器改版:Company/Site group 同步、System client role 同步、Site 角色套用同步。 -- [ ] Swagger response model 全面更新為 role-site 新模型。 -- [ ] 新增/補齊 API 測試(CRUD、關聯、同步、刪除、錯誤碼)。 +- [ ] 補齊 pytest API 測試(CRUD、關聯、同步、刪除、錯誤碼)。 +- [ ] 補一支「一鍵重建 schema」腳本(串 `init_schema.sql`)。 ## 進行中 -- [ ] 文件與實際回應格式持續對齊(避免文件漂移)。 +- [ ] Swagger response model 與前端實際畫面持續對齊。 ## 已完成 -- [x] Keycloak OIDC 登入主流程(authorize + callback + token)。 -- [x] `/admin/*` Bearer token + admin 群組白名單安全線。 -- [x] API 白名單基礎(`api_clients`)已存在並可管理。 -- [x] 前後端本地啟動流程已可分開運行(backend + frontend)。 +- [x] schema 重建:清除舊表,改為 `roles`、`site_roles`、`user_sites` 主流程。 +- [x] 移除舊後端程式:`module/permission_group/user_scope_permissions` 相關 model/repo/api。 +- [x] Admin API 改版:`Company/Site/System/Role/User` CRUD 與關聯 API。 +- [x] Role 指派只允許綁 Site,不提供 user direct role API。 +- [x] `/me/permissions/snapshot` 改為 role 聚合格式。 +- [x] Internal API 改版:可取 `users/{user_sub}/roles` 聚合結果。 +- [x] 保留 `api_clients` 白名單管理 API。 +- [x] Keycloak OIDC 登入主流程。 +- [x] `/admin/*` Bearer + admin 群組白名單安全線。 diff --git a/docs/INTERNAL_API_HANDOFF.md b/docs/INTERNAL_API_HANDOFF.md index d6db526..8dee0af 100644 --- a/docs/INTERNAL_API_HANDOFF.md +++ b/docs/INTERNAL_API_HANDOFF.md @@ -13,26 +13,38 @@ { "detail": "error_code" } ``` -## 資源模型(重點) -- `company`: `id`, `company_key`, `display_name`, `legal_name`, `status` -- `site`: `id`, `site_key`, `company_id`, `display_name`, `domain`, `status` -- `system`: `id`, `system_key`, `name`, `idp_client_id`, `status` -- `role`: `id`, `role_key`, `system_id`, `name`, `description`, `idp_role_name`, `status` -- `user`: `id`, `user_sub`, `username`, `email`, `display_name`, `is_active`, `status` - -## 主要端點(目標) +## 已實作端點 1. `GET /internal/companies` 2. `GET /internal/sites` 3. `GET /internal/systems` 4. `GET /internal/roles` -5. `GET /internal/users` -6. `GET /internal/users/{user_sub}/roles` -- 回傳該 user 透過 site 推導出的最終 roles。 +5. `GET /internal/members` +6. `POST /internal/users/upsert-by-sub` +7. `GET /internal/users/{user_sub}/roles` +8. `GET /internal/permissions/{user_sub}/snapshot`(相容路徑,回 role 聚合資料) +9. `POST /internal/idp/users/ensure` +10. `POST /internal/keycloak/users/ensure` -## 關聯端點(目標) -1. `POST /internal/site-roles` / `DELETE /internal/site-roles/{id}` -2. `POST /internal/user-sites` / `DELETE /internal/user-sites/{id}` +## 角色聚合回應(`GET /internal/users/{user_sub}/roles`) +```json +{ + "user_sub": "xxxxxxxx", + "roles": [ + { + "site_key": "ST20260402X1234", + "site_display_name": "OSE Main", + "company_key": "CP20260402X5678", + "company_display_name": "OSE", + "system_key": "SY20260402X0001", + "system_name": "Marketing", + "role_key": "RL20260402X0002", + "role_name": "campaign_edit", + "idp_role_name": "campaign_edit" + } + ] +} +``` ## 注意事項 - 不提供 user direct role 寫入 API。 -- 若其他系統需要判斷某 user 可否做某事,請吃 `users/{user_sub}/roles` 聚合結果。 +- User 最終角色由 `user_sites` + `site_roles` 推導。 diff --git a/docs/LOCAL_DEV_RUNBOOK.md b/docs/LOCAL_DEV_RUNBOOK.md index 36adc02..45b3572 100644 --- a/docs/LOCAL_DEV_RUNBOOK.md +++ b/docs/LOCAL_DEV_RUNBOOK.md @@ -1,6 +1,13 @@ # Local Dev Runbook(Keycloak) -## 1) 啟動後端 +## 1) 先重建 DB schema(本次改版必做) +```bash +cd backend +psql "$DATABASE_URL" -f scripts/init_schema.sql +``` +- DB schema 檔案:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql) + +## 2) 啟動後端 ```bash cd backend ./scripts/start_dev.sh @@ -8,7 +15,7 @@ cd backend - 專案路徑:[backend](../backend) - 啟動腳本:[backend/scripts/start_dev.sh](../backend/scripts/start_dev.sh) -## 2) 啟動前端 +## 3) 啟動前端 ```bash cd frontend npm install @@ -16,7 +23,7 @@ npm run dev ``` - 專案路徑:[frontend](../frontend) -## 3) 必要環境變數([backend/.env.development](../backend/.env.development)) +## 4) 必要環境變數([backend/.env.development](../backend/.env.development)) - `KEYCLOAK_BASE_URL` - `KEYCLOAK_REALM` - `KEYCLOAK_CLIENT_ID` @@ -25,20 +32,20 @@ npm run dev - `KEYCLOAK_ADMIN_CLIENT_SECRET` - `ADMIN_REQUIRED_GROUPS` -## 4) 基本檢查 +## 5) 基本檢查 1. `GET http://127.0.0.1:8000/healthz` 應為 200。 2. 前端按「前往 Keycloak 登入」應可成功導轉與回跳。 3. `GET /me` 登入後應有資料。 4. 非 admin 群組帳號打 `/admin/*` 應為 403。 -## 5) 新模型驗收路徑 +## 6) 新模型驗收路徑 1. 新增 Company、Site。 2. 新增 System、Role。 3. 對 Site 指派 Role。 4. 新增 User,加入 Site。 5. 驗證 User 的角色是由 Site 推導,不是 direct assign。 -## 6) API 白名單驗收 +## 7) API 白名單驗收 1. 建立 `api_client`。 2. 用 `X-Client-Key` + `X-API-Key` 呼叫 `/internal/*`。 3. 驗證未授權 key 會被拒絕。