diff --git a/backend/README.md b/backend/README.md index 7052551..e94dcd7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -43,7 +43,8 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' ## Main APIs - `GET /healthz` -- `POST /auth/login` +- `GET /auth/oidc/url` +- `POST /auth/oidc/exchange` - `GET /me` (Bearer token required) - `GET /me/permissions/snapshot` (Bearer token required) - `POST /internal/users/upsert-by-sub` @@ -51,11 +52,16 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' - `POST /internal/authentik/users/ensure` - `POST /admin/permissions/grant` - `POST /admin/permissions/revoke` -- `GET|POST|PATCH /admin/organizations...` -- `GET|POST|PATCH /admin/members...` -- `GET|POST|DELETE /admin/members/{member_id}/organizations...` +- `GET|POST /admin/systems` +- `GET|POST /admin/modules` +- `GET|POST /admin/companies` +- `GET|POST /admin/sites` +- `GET /admin/members` +- `GET|POST /admin/permission-groups` +- `POST|DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}` +- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke` +- `GET /internal/systems` +- `GET /internal/modules` +- `GET /internal/companies` +- `GET /internal/sites` - `GET /internal/members` -- `GET /internal/members/by-sub/{authentik_sub}` -- `GET /internal/organizations` -- `GET /internal/organizations/by-code/{org_code}` -- `GET /internal/members/{member_id}/organizations` diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 947ed36..5be9951 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -3,7 +3,11 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.models.api_client import ApiClient +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 PermissionGrantRequest, PermissionRevokeRequest from app.security.api_client_auth import require_api_client @@ -11,6 +15,36 @@ from app.security.api_client_auth import require_api_client router = APIRouter(prefix="/admin", tags=["admin"]) +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 = f"{system_key}.{module_key}" if module_key else f"{system_key}.__system__" + module = modules_repo.get_by_key(target_module_key) + if not module: + module = modules_repo.create(module_key=target_module_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, @@ -26,12 +60,15 @@ def grant_permission( 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, - scope_type=payload.scope_type, - scope_id=payload.scope_id, - module=payload.module, + 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"} @@ -50,11 +87,14 @@ def revoke_permission( 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, - scope_type=payload.scope_type, - scope_id=payload.scope_id, - module=payload.module, + 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"} diff --git a/backend/app/api/admin_catalog.py b/backend/app/api/admin_catalog.py new file mode 100644 index 0000000..7eb71e1 --- /dev/null +++ b/backend/app/api/admin_catalog.py @@ -0,0 +1,328 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.api_client import ApiClient +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.sites_repo import SitesRepository +from app.repositories.systems_repo import SystemsRepository +from app.repositories.users_repo import UsersRepository +from app.schemas.catalog import ( + CompanyCreateRequest, + CompanyItem, + MemberItem, + ModuleCreateRequest, + ModuleItem, + PermissionGroupCreateRequest, + PermissionGroupItem, + SiteCreateRequest, + SiteItem, + SystemCreateRequest, + SystemItem, +) +from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest +from app.security.api_client_auth import require_api_client + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +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 = f"{system_key}.{module_key}" if module_key else f"{system_key}.__system__" + module = modules_repo.get_by_key(target_module_key) + if not module: + module = modules_repo.create(module_key=target_module_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.get("/systems") +def list_systems( + _: ApiClient = Depends(require_api_client), + 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, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> SystemItem: + repo = SystemsRepository(db) + if repo.get_by_key(payload.system_key): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_key_already_exists") + row = repo.create(system_key=payload.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.get("/modules") +def list_modules( + _: ApiClient = Depends(require_api_client), + 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: + system_key = i.module_key.split(".", 1)[0] if "." in i.module_key else None + out.append( + ModuleItem( + id=i.id, + system_key=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, + _: ApiClient = Depends(require_api_client), + 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") + full_module_key = f"{payload.system_key}.{payload.module_key}" + if modules_repo.get_by_key(full_module_key): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="module_key_already_exists") + row = modules_repo.create( + module_key=full_module_key, + name=payload.name, + status=payload.status, + ) + return ModuleItem(id=row.id, system_key=payload.system_key, module_key=row.module_key, name=row.name, status=row.status) + + +@router.get("/companies") +def list_companies( + _: ApiClient = Depends(require_api_client), + 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: + 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} + + +@router.post("/companies", response_model=CompanyItem) +def create_company( + payload: CompanyCreateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> CompanyItem: + repo = CompaniesRepository(db) + if repo.get_by_key(payload.company_key): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="company_key_already_exists") + row = repo.create(company_key=payload.company_key, name=payload.name, status=payload.status) + return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status) + + +@router.get("/sites") +def list_sites( + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), + company_key: str | None = Query(default=None), + keyword: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> dict: + 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") + company_id = company.id + 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, + } + + +@router.post("/sites", response_model=SiteItem) +def create_site( + payload: SiteCreateRequest, + _: ApiClient = Depends(require_api_client), + 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") + if sites_repo.get_by_key(payload.site_key): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="site_key_already_exists") + row = sites_repo.create(site_key=payload.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) + + +@router.get("/members") +def list_members( + _: ApiClient = Depends(require_api_client), + 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: + users_repo = UsersRepository(db) + items, total = users_repo.list(keyword=keyword, limit=limit, offset=offset) + return {"items": [MemberItem(id=i.id, authentik_sub=i.authentik_sub, email=i.email, display_name=i.display_name, is_active=i.is_active).model_dump() for i in items], "total": total, "limit": limit, "offset": offset} + + +@router.get("/permission-groups") +def list_permission_groups( + _: ApiClient = Depends(require_api_client), + 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.post("/permission-groups", response_model=PermissionGroupItem) +def create_permission_group( + payload: PermissionGroupCreateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> PermissionGroupItem: + repo = PermissionGroupsRepository(db) + if repo.get_by_key(payload.group_key): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="group_key_already_exists") + row = repo.create(group_key=payload.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.post("/permission-groups/{group_key}/members/{authentik_sub}") +def add_group_member( + group_key: str, + authentik_sub: str, + _: ApiClient = Depends(require_api_client), + 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, authentik_sub) + return {"membership_id": row.id, "result": "added"} + + +@router.delete("/permission-groups/{group_key}/members/{authentik_sub}") +def remove_group_member( + group_key: str, + authentik_sub: str, + _: ApiClient = Depends(require_api_client), + 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, authentik_sub) + return {"deleted": deleted, "result": "removed"} + + +@router.post("/permission-groups/{group_key}/permissions/grant") +def grant_group_permission( + group_key: str, + payload: PermissionGrantRequest, + _: ApiClient = Depends(require_api_client), + 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_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__" + row = groups_repo.grant_group_permission( + group_id=group.id, + system=payload.system, + module=module_key, + 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, + _: ApiClient = Depends(require_api_client), + 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_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__" + deleted = groups_repo.revoke_group_permission( + group_id=group.id, + system=payload.system, + module=module_key, + action=payload.action, + scope_type=payload.scope_type, + scope_id=payload.scope_id, + ) + return {"deleted": deleted, "result": "revoked"} diff --git a/backend/app/api/admin_entities.py b/backend/app/api/admin_entities.py deleted file mode 100644 index 89c1531..0000000 --- a/backend/app/api/admin_entities.py +++ /dev/null @@ -1,265 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.models.api_client import ApiClient -from app.repositories.member_organizations_repo import MemberOrganizationsRepository -from app.repositories.organizations_repo import OrganizationsRepository -from app.repositories.users_repo import UsersRepository -from app.schemas.members import ( - MemberCreateRequest, - MemberListResponse, - MemberOrganizationsResponse, - MemberSummary, - MemberUpdateRequest, -) -from app.schemas.organizations import ( - OrganizationCreateRequest, - OrganizationListResponse, - OrganizationSummary, - OrganizationUpdateRequest, -) -from app.security.api_client_auth import require_api_client - -router = APIRouter(prefix="/admin", tags=["admin"]) - - -def _to_member_summary(member) -> MemberSummary: - return MemberSummary( - id=member.id, - authentik_sub=member.authentik_sub, - authentik_user_id=member.authentik_user_id, - email=member.email, - display_name=member.display_name, - is_active=member.is_active, - ) - - -def _to_org_summary(org) -> OrganizationSummary: - return OrganizationSummary( - id=org.id, - org_code=org.org_code, - name=org.name, - tax_id=org.tax_id, - status=org.status, - ) - - -@router.get("/organizations", response_model=OrganizationListResponse) -def list_organizations( - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - status_filter: str | None = Query(default=None, alias="status"), - limit: int = Query(default=50, ge=1, le=200), - offset: int = Query(default=0, ge=0), -) -> OrganizationListResponse: - repo = OrganizationsRepository(db) - items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) - return OrganizationListResponse(items=[_to_org_summary(i) for i in items], total=total, limit=limit, offset=offset) - - -@router.post("/organizations", response_model=OrganizationSummary) -def create_organization( - payload: OrganizationCreateRequest, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> OrganizationSummary: - repo = OrganizationsRepository(db) - existing = repo.get_by_code(payload.org_code) - if existing: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="organization_code_already_exists") - org = repo.create(org_code=payload.org_code, name=payload.name, tax_id=payload.tax_id, status=payload.status) - return _to_org_summary(org) - - -@router.get("/organizations/{org_id}", response_model=OrganizationSummary) -def get_organization( - org_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> OrganizationSummary: - repo = OrganizationsRepository(db) - org = repo.get_by_id(org_id) - if not org: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") - return _to_org_summary(org) - - -@router.patch("/organizations/{org_id}", response_model=OrganizationSummary) -def update_organization( - org_id: str, - payload: OrganizationUpdateRequest, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> OrganizationSummary: - repo = OrganizationsRepository(db) - org = repo.get_by_id(org_id) - if not org: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") - updated = repo.update(org, name=payload.name, tax_id=payload.tax_id, status=payload.status) - return _to_org_summary(updated) - - -@router.post("/organizations/{org_id}/activate", response_model=OrganizationSummary) -def activate_organization( - org_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> OrganizationSummary: - repo = OrganizationsRepository(db) - org = repo.get_by_id(org_id) - if not org: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") - return _to_org_summary(repo.update(org, status="active")) - - -@router.post("/organizations/{org_id}/deactivate", response_model=OrganizationSummary) -def deactivate_organization( - org_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> OrganizationSummary: - repo = OrganizationsRepository(db) - org = repo.get_by_id(org_id) - if not org: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") - return _to_org_summary(repo.update(org, status="inactive")) - - -@router.get("/members", response_model=MemberListResponse) -def list_members( - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - is_active: bool | None = Query(default=None), - limit: int = Query(default=50, ge=1, le=200), - offset: int = Query(default=0, ge=0), -) -> MemberListResponse: - repo = UsersRepository(db) - items, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset) - return MemberListResponse(items=[_to_member_summary(i) for i in items], total=total, limit=limit, offset=offset) - - -@router.post("/members", response_model=MemberSummary) -def create_member( - payload: MemberCreateRequest, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> MemberSummary: - repo = UsersRepository(db) - existing = repo.get_by_sub(payload.authentik_sub) - if existing: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="member_sub_already_exists") - member = repo.upsert_by_sub( - authentik_sub=payload.authentik_sub, - email=payload.email, - display_name=payload.display_name, - is_active=payload.is_active, - ) - return _to_member_summary(member) - - -@router.get("/members/{member_id}", response_model=MemberSummary) -def get_member( - member_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> MemberSummary: - repo = UsersRepository(db) - member = repo.get_by_id(member_id) - if not member: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - return _to_member_summary(member) - - -@router.patch("/members/{member_id}", response_model=MemberSummary) -def update_member( - member_id: str, - payload: MemberUpdateRequest, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> MemberSummary: - repo = UsersRepository(db) - member = repo.get_by_id(member_id) - if not member: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - updated = repo.update_member(member, email=payload.email, display_name=payload.display_name, is_active=payload.is_active) - return _to_member_summary(updated) - - -@router.post("/members/{member_id}/activate", response_model=MemberSummary) -def activate_member( - member_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> MemberSummary: - repo = UsersRepository(db) - member = repo.get_by_id(member_id) - if not member: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - return _to_member_summary(repo.update_member(member, is_active=True)) - - -@router.post("/members/{member_id}/deactivate", response_model=MemberSummary) -def deactivate_member( - member_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> MemberSummary: - repo = UsersRepository(db) - member = repo.get_by_id(member_id) - if not member: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - return _to_member_summary(repo.update_member(member, is_active=False)) - - -@router.get("/members/{member_id}/organizations", response_model=MemberOrganizationsResponse) -def list_member_organizations( - member_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> MemberOrganizationsResponse: - users_repo = UsersRepository(db) - link_repo = MemberOrganizationsRepository(db) - - member = users_repo.get_by_id(member_id) - if not member: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - - orgs = link_repo.list_organizations_by_member_id(member_id) - return MemberOrganizationsResponse(member=_to_member_summary(member), organizations=[_to_org_summary(o) for o in orgs]) - - -@router.post("/members/{member_id}/organizations/{org_id}") -def bind_member_organization( - member_id: str, - org_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> dict[str, str]: - users_repo = UsersRepository(db) - orgs_repo = OrganizationsRepository(db) - link_repo = MemberOrganizationsRepository(db) - - member = users_repo.get_by_id(member_id) - if not member: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - org = orgs_repo.get_by_id(org_id) - if not org: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") - - relation = link_repo.add_if_not_exists(member_id=member_id, organization_id=org_id) - return {"relation_id": relation.id, "result": "bound"} - - -@router.delete("/members/{member_id}/organizations/{org_id}") -def unbind_member_organization( - member_id: str, - org_id: str, - _: ApiClient = Depends(require_api_client), - db: Session = Depends(get_db), -) -> dict[str, int | str]: - link_repo = MemberOrganizationsRepository(db) - deleted = link_repo.remove(member_id=member_id, organization_id=org_id) - return {"deleted": deleted, "result": "unbound"} diff --git a/backend/app/api/internal.py b/backend/app/api/internal.py index 903d194..8e65066 100644 --- a/backend/app/api/internal.py +++ b/backend/app/api/internal.py @@ -58,9 +58,8 @@ def get_permission_snapshot( if user is None: return PermissionSnapshotResponse(authentik_sub=authentik_sub, permissions=[]) - permissions = perms_repo.list_by_user_id(user.id) - tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions] - return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=tuples) + permissions = perms_repo.list_by_user(user.id, user.authentik_sub) + return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=permissions) @router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse) diff --git a/backend/app/api/internal_catalog.py b/backend/app/api/internal_catalog.py new file mode 100644 index 0000000..e8b4dd5 --- /dev/null +++ b/backend/app/api/internal_catalog.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.internal import verify_internal_secret +from app.db.session import get_db +from app.repositories.companies_repo import CompaniesRepository +from app.repositories.modules_repo import ModulesRepository +from app.repositories.sites_repo import SitesRepository +from app.repositories.systems_repo import SystemsRepository +from app.repositories.users_repo import UsersRepository + +router = APIRouter(prefix="/internal", tags=["internal"]) + + +@router.get("/systems") +def internal_list_systems( + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), + limit: int = Query(default=200, ge=1, le=1000), + offset: int = Query(default=0, ge=0), +) -> dict: + 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") +def internal_list_modules( + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), + limit: int = Query(default=500, ge=1, le=2000), + offset: int = Query(default=0, ge=0), +) -> dict: + 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.module_key.split(".", 1)[0] if "." in i.module_key else None, + "name": i.name, + "status": i.status, + } + for i in items + ], + "total": total, + "limit": limit, + "offset": offset, + } + + +@router.get("/companies") +def internal_list_companies( + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=2000), + offset: int = Query(default=0, ge=0), +) -> dict: + 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} + + +@router.get("/sites") +def internal_list_sites( + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), + company_key: str | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=2000), + offset: int = Query(default=0, ge=0), +) -> dict: + companies_repo = CompaniesRepository(db) + sites_repo = SitesRepository(db) + company_id = None + if company_key: + 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} + 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} + + +@router.get("/members") +def internal_list_members( + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=2000), + offset: int = Query(default=0, ge=0), +) -> dict: + repo = UsersRepository(db) + items, total = repo.list(keyword=keyword, limit=limit, offset=offset) + return {"items": [{"id": i.id, "authentik_sub": i.authentik_sub, "email": i.email, "display_name": i.display_name, "is_active": i.is_active} for i in items], "total": total, "limit": limit, "offset": offset} diff --git a/backend/app/api/internal_entities.py b/backend/app/api/internal_entities.py deleted file mode 100644 index b728341..0000000 --- a/backend/app/api/internal_entities.py +++ /dev/null @@ -1,100 +0,0 @@ -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session - -from app.api.internal import verify_internal_secret -from app.db.session import get_db -from app.repositories.member_organizations_repo import MemberOrganizationsRepository -from app.repositories.organizations_repo import OrganizationsRepository -from app.repositories.users_repo import UsersRepository -from app.schemas.members import MemberListResponse, MemberOrganizationsResponse, MemberSummary -from app.schemas.organizations import OrganizationListResponse, OrganizationSummary - -router = APIRouter(prefix="/internal", tags=["internal"]) - - -def _to_member_summary(member) -> MemberSummary: - return MemberSummary( - id=member.id, - authentik_sub=member.authentik_sub, - authentik_user_id=member.authentik_user_id, - email=member.email, - display_name=member.display_name, - is_active=member.is_active, - ) - - -def _to_org_summary(org) -> OrganizationSummary: - return OrganizationSummary( - id=org.id, - org_code=org.org_code, - name=org.name, - tax_id=org.tax_id, - status=org.status, - ) - - -@router.get("/members", response_model=MemberListResponse) -def internal_list_members( - _: None = Depends(verify_internal_secret), - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - is_active: bool | None = Query(default=None), - limit: int = Query(default=200, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> MemberListResponse: - repo = UsersRepository(db) - items, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset) - return MemberListResponse(items=[_to_member_summary(i) for i in items], total=total, limit=limit, offset=offset) - - -@router.get("/members/by-sub/{authentik_sub}", response_model=MemberSummary | None) -def internal_get_member_by_sub( - authentik_sub: str, - _: None = Depends(verify_internal_secret), - db: Session = Depends(get_db), -) -> MemberSummary | None: - repo = UsersRepository(db) - member = repo.get_by_sub(authentik_sub) - return _to_member_summary(member) if member else None - - -@router.get("/organizations", response_model=OrganizationListResponse) -def internal_list_organizations( - _: None = Depends(verify_internal_secret), - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - status_filter: str | None = Query(default=None, alias="status"), - limit: int = Query(default=200, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> OrganizationListResponse: - repo = OrganizationsRepository(db) - items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) - return OrganizationListResponse(items=[_to_org_summary(i) for i in items], total=total, limit=limit, offset=offset) - - -@router.get("/organizations/by-code/{org_code}", response_model=OrganizationSummary | None) -def internal_get_organization_by_code( - org_code: str, - _: None = Depends(verify_internal_secret), - db: Session = Depends(get_db), -) -> OrganizationSummary | None: - repo = OrganizationsRepository(db) - org = repo.get_by_code(org_code) - return _to_org_summary(org) if org else None - - -@router.get("/members/{member_id}/organizations", response_model=MemberOrganizationsResponse | None) -def internal_list_member_organizations( - member_id: str, - _: None = Depends(verify_internal_secret), - db: Session = Depends(get_db), -) -> MemberOrganizationsResponse | None: - users_repo = UsersRepository(db) - link_repo = MemberOrganizationsRepository(db) - - member = users_repo.get_by_id(member_id) - if not member: - return None - - orgs = link_repo.list_organizations_by_member_id(member_id) - return MemberOrganizationsResponse(member=_to_member_summary(member), organizations=[_to_org_summary(o) for o in orgs]) diff --git a/backend/app/api/me.py b/backend/app/api/me.py index ac72227..05285fa 100644 --- a/backend/app/api/me.py +++ b/backend/app/api/me.py @@ -51,8 +51,7 @@ def get_my_permission_snapshot( display_name=principal.name or principal.preferred_username, is_active=True, ) - permissions = perms_repo.list_by_user_id(user.id) - tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions] - return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=tuples) + permissions = perms_repo.list_by_user(user.id, user.authentik_sub) + return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=permissions) except SQLAlchemyError: return PermissionSnapshotResponse(authentik_sub=principal.sub, permissions=[]) diff --git a/backend/app/main.py b/backend/app/main.py index e8ef733..48d4624 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,10 +2,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.admin import router as admin_router -from app.api.admin_entities import router as admin_entities_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 from app.api.internal import router as internal_router -from app.api.internal_entities import router as internal_entities_router from app.api.me import router as me_router from app.core.config import get_settings @@ -27,8 +27,8 @@ def healthz() -> dict[str, str]: app.include_router(internal_router) -app.include_router(internal_entities_router) +app.include_router(internal_catalog_router) app.include_router(admin_router) -app.include_router(admin_entities_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 44a7f90..235653c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,7 +1,25 @@ from app.models.api_client import ApiClient -from app.models.member_organization import MemberOrganization -from app.models.organization import Organization +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.site import Site +from app.models.system import System from app.models.user import User +from app.models.user_scope_permission import UserScopePermission -__all__ = ["ApiClient", "MemberOrganization", "Organization", "Permission", "User"] +__all__ = [ + "ApiClient", + "Company", + "Module", + "Permission", + "PermissionGroup", + "PermissionGroupMember", + "PermissionGroupPermission", + "Site", + "System", + "User", + "UserScopePermission", +] diff --git a/backend/app/models/organization.py b/backend/app/models/company.py similarity index 77% rename from backend/app/models/organization.py rename to backend/app/models/company.py index 12ee320..f0081b3 100644 --- a/backend/app/models/organization.py +++ b/backend/app/models/company.py @@ -8,15 +8,13 @@ from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base -class Organization(Base): - __tablename__ = "organizations" +class Company(Base): + __tablename__ = "companies" id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - org_code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + company_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - tax_id: Mapped[str | None] = mapped_column(String(32)) 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( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False diff --git a/backend/app/models/member_organization.py b/backend/app/models/member_organization.py deleted file mode 100644 index 19ec119..0000000 --- a/backend/app/models/member_organization.py +++ /dev/null @@ -1,23 +0,0 @@ -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 MemberOrganization(Base): - __tablename__ = "member_organizations" - __table_args__ = ( - UniqueConstraint("member_id", "organization_id", name="uq_member_organizations_member_org"), - ) - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - member_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - organization_id: Mapped[str] = mapped_column( - UUID(as_uuid=False), ForeignKey("organizations.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/module.py b/backend/app/models/module.py new file mode 100644 index 0000000..cc8ca2f --- /dev/null +++ b/backend/app/models/module.py @@ -0,0 +1,21 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, String, 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" + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) + module_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") + 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/permission_group.py new file mode 100644 index 0000000..f7f5a5c --- /dev/null +++ b/backend/app/models/permission_group.py @@ -0,0 +1,21 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, String, 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" + + 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") + 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_member.py b/backend/app/models/permission_group_member.py new file mode 100644 index 0000000..1f58494 --- /dev/null +++ b/backend/app/models/permission_group_member.py @@ -0,0 +1,20 @@ +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", "authentik_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 + ) + authentik_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 new file mode 100644 index 0000000..0d819e0 --- /dev/null +++ b/backend/app/models/permission_group_permission.py @@ -0,0 +1,23 @@ +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/site.py b/backend/app/models/site.py new file mode 100644 index 0000000..eafdd2d --- /dev/null +++ b/backend/app/models/site.py @@ -0,0 +1,22 @@ +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 Site(Base): + __tablename__ = "sites" + + 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) + 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( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) diff --git a/backend/app/models/system.py b/backend/app/models/system.py new file mode 100644 index 0000000..c5ee6ae --- /dev/null +++ b/backend/app/models/system.py @@ -0,0 +1,21 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class System(Base): + __tablename__ = "systems" + + 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) + 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( + 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 new file mode 100644 index 0000000..08f243f --- /dev/null +++ b/backend/app/models/user_scope_permission.py @@ -0,0 +1,24 @@ +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/repositories/companies_repo.py b/backend/app/repositories/companies_repo.py new file mode 100644 index 0000000..47628f2 --- /dev/null +++ b/backend/app/repositories/companies_repo.py @@ -0,0 +1,35 @@ +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.models.company import Company + + +class CompaniesRepository: + def __init__(self, db: Session) -> None: + 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) + + def get_by_id(self, company_id: str) -> Company | None: + stmt = select(Company).where(Company.id == company_id) + return self.db.scalar(stmt) + + 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)) + 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) + self.db.add(item) + self.db.commit() + self.db.refresh(item) + return item diff --git a/backend/app/repositories/member_organizations_repo.py b/backend/app/repositories/member_organizations_repo.py deleted file mode 100644 index d9345a0..0000000 --- a/backend/app/repositories/member_organizations_repo.py +++ /dev/null @@ -1,43 +0,0 @@ -from sqlalchemy import delete, select -from sqlalchemy.orm import Session - -from app.models.member_organization import MemberOrganization -from app.models.organization import Organization - - -class MemberOrganizationsRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def list_organizations_by_member_id(self, member_id: str) -> list[Organization]: - stmt = ( - select(Organization) - .join(MemberOrganization, MemberOrganization.organization_id == Organization.id) - .where(MemberOrganization.member_id == member_id) - .order_by(Organization.name.asc()) - ) - return list(self.db.scalars(stmt).all()) - - def add_if_not_exists(self, member_id: str, organization_id: str) -> MemberOrganization: - stmt = select(MemberOrganization).where( - MemberOrganization.member_id == member_id, - MemberOrganization.organization_id == organization_id, - ) - existing = self.db.scalar(stmt) - if existing: - return existing - - relation = MemberOrganization(member_id=member_id, organization_id=organization_id) - self.db.add(relation) - self.db.commit() - self.db.refresh(relation) - return relation - - def remove(self, member_id: str, organization_id: str) -> int: - stmt = delete(MemberOrganization).where( - MemberOrganization.member_id == member_id, - MemberOrganization.organization_id == organization_id, - ) - result = self.db.execute(stmt) - self.db.commit() - return int(result.rowcount or 0) diff --git a/backend/app/repositories/modules_repo.py b/backend/app/repositories/modules_repo.py new file mode 100644 index 0000000..b457266 --- /dev/null +++ b/backend/app/repositories/modules_repo.py @@ -0,0 +1,26 @@ +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, name: str, status: str = "active") -> Module: + item = Module(module_key=module_key, name=name, status=status) + self.db.add(item) + self.db.commit() + self.db.refresh(item) + return item diff --git a/backend/app/repositories/organizations_repo.py b/backend/app/repositories/organizations_repo.py deleted file mode 100644 index 627d8cc..0000000 --- a/backend/app/repositories/organizations_repo.py +++ /dev/null @@ -1,67 +0,0 @@ -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.models.organization import Organization - - -class OrganizationsRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def list( - self, - keyword: str | None = None, - status: str | None = None, - limit: int = 50, - offset: int = 0, - ) -> tuple[list[Organization], int]: - stmt = select(Organization) - count_stmt = select(func.count()).select_from(Organization) - - if keyword: - pattern = f"%{keyword}%" - cond = or_(Organization.org_code.ilike(pattern), Organization.name.ilike(pattern)) - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - - if status: - stmt = stmt.where(Organization.status == status) - count_stmt = count_stmt.where(Organization.status == status) - - stmt = stmt.order_by(Organization.created_at.desc()).limit(limit).offset(offset) - items = list(self.db.scalars(stmt).all()) - total = int(self.db.scalar(count_stmt) or 0) - return items, total - - def get_by_id(self, org_id: str) -> Organization | None: - stmt = select(Organization).where(Organization.id == org_id) - return self.db.scalar(stmt) - - def get_by_code(self, org_code: str) -> Organization | None: - stmt = select(Organization).where(Organization.org_code == org_code) - return self.db.scalar(stmt) - - def create(self, org_code: str, name: str, tax_id: str | None, status: str = "active") -> Organization: - org = Organization(org_code=org_code, name=name, tax_id=tax_id, status=status) - self.db.add(org) - self.db.commit() - self.db.refresh(org) - return org - - def update( - self, - org: Organization, - *, - name: str | None = None, - tax_id: str | None = None, - status: str | None = None, - ) -> Organization: - if name is not None: - org.name = name - if tax_id is not None: - org.tax_id = tax_id - if status is not None: - org.status = status - self.db.commit() - self.db.refresh(org) - return org diff --git a/backend/app/repositories/permission_groups_repo.py b/backend/app/repositories/permission_groups_repo.py new file mode 100644 index 0000000..a1fa6e4 --- /dev/null +++ b/backend/app/repositories/permission_groups_repo.py @@ -0,0 +1,106 @@ +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 + + +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 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 add_member_if_not_exists(self, group_id: str, authentik_sub: str) -> PermissionGroupMember: + existing = self.db.scalar( + select(PermissionGroupMember).where( + PermissionGroupMember.group_id == group_id, PermissionGroupMember.authentik_sub == authentik_sub + ) + ) + if existing: + return existing + row = PermissionGroupMember(group_id=group_id, authentik_sub=authentik_sub) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return row + + def remove_member(self, group_id: str, authentik_sub: str) -> int: + result = self.db.execute( + delete(PermissionGroupMember).where( + PermissionGroupMember.group_id == group_id, PermissionGroupMember.authentik_sub == authentik_sub + ) + ) + self.db.commit() + return int(result.rowcount or 0) + + 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 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 index 82ab800..5c19b93 100644 --- a/backend/app/repositories/permissions_repo.py +++ b/backend/app/repositories/permissions_repo.py @@ -1,42 +1,93 @@ -from sqlalchemy import delete, select +from sqlalchemy import and_, delete, or_, select from sqlalchemy.orm import Session -from app.models.permission import Permission +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_scope_permission import UserScopePermission class PermissionsRepository: def __init__(self, db: Session) -> None: self.db = db - def list_by_user_id(self, user_id: str) -> list[Permission]: - stmt = select(Permission).where(Permission.user_id == user_id) - return list(self.db.scalars(stmt).all()) + def list_by_user(self, user_id: str, authentik_sub: str) -> list[tuple[str, str, str | None, str, str]]: + direct_stmt = ( + select( + UserScopePermission.scope_type, + Company.company_key, + Site.site_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) + ) + group_stmt = ( + select( + 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.authentik_sub == authentik_sub) + ) + 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: + if len(row) == 5: + scope_type, scope_id, system_key, module_key, action = row + else: + scope_type, company_key, site_key, module_key, action = row + scope_id = company_key if scope_type == "company" else site_key + system_key = module_key.split(".", 1)[0] if isinstance(module_key, str) and "." in module_key else None + 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, - scope_type: str, - scope_id: str, - module: str, + module_id: str, action: str, - ) -> Permission: - stmt = select(Permission).where( - Permission.user_id == user_id, - Permission.scope_type == scope_type, - Permission.scope_id == scope_id, - Permission.module == module, - Permission.action == action, - ) - existing = self.db.scalar(stmt) + 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 = Permission( + item = UserScopePermission( user_id=user_id, - scope_type=scope_type, - scope_id=scope_id, - module=module, + 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() @@ -46,17 +97,21 @@ class PermissionsRepository: def revoke( self, user_id: str, - scope_type: str, - scope_id: str, - module: str, + module_id: str, action: str, + scope_type: str, + company_id: str | None, + site_id: str | None, ) -> int: - stmt = delete(Permission).where( - Permission.user_id == user_id, - Permission.scope_type == scope_type, - Permission.scope_id == scope_id, - Permission.module == module, - Permission.action == action, + 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() diff --git a/backend/app/repositories/sites_repo.py b/backend/app/repositories/sites_repo.py new file mode 100644 index 0000000..d3e58c4 --- /dev/null +++ b/backend/app/repositories/sites_repo.py @@ -0,0 +1,40 @@ +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.models.site import Site + + +class SitesRepository: + def __init__(self, db: Session) -> None: + 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) + + def list( + self, + keyword: str | None = None, + company_id: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> 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)) + 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) + self.db.add(item) + self.db.commit() + self.db.refresh(item) + return item diff --git a/backend/app/repositories/systems_repo.py b/backend/app/repositories/systems_repo.py new file mode 100644 index 0000000..0464fea --- /dev/null +++ b/backend/app/repositories/systems_repo.py @@ -0,0 +1,33 @@ +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.system import System + + +class SystemsRepository: + def __init__(self, db: Session) -> None: + 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) + + def get_by_id(self, system_id: str) -> System | None: + stmt = select(System).where(System.id == system_id) + return self.db.scalar(stmt) + + def list(self, 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 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) + self.db.add(item) + self.db.commit() + self.db.refresh(item) + return item diff --git a/backend/app/schemas/catalog.py b/backend/app/schemas/catalog.py new file mode 100644 index 0000000..90c0d98 --- /dev/null +++ b/backend/app/schemas/catalog.py @@ -0,0 +1,85 @@ +from pydantic import BaseModel + + +class SystemCreateRequest(BaseModel): + system_key: str + name: str + status: str = "active" + + +class SystemItem(BaseModel): + id: str + system_key: str + name: str + status: str + + +class ModuleCreateRequest(BaseModel): + system_key: str + module_key: str + name: str + status: str = "active" + + +class ModuleItem(BaseModel): + id: str + system_key: str | None = None + module_key: str + name: str + status: str + + +class CompanyCreateRequest(BaseModel): + company_key: str + name: str + status: str = "active" + + +class CompanyItem(BaseModel): + id: str + company_key: str + name: str + status: str + + +class SiteCreateRequest(BaseModel): + site_key: str + company_key: str + name: str + status: str = "active" + + +class SiteItem(BaseModel): + id: str + site_key: str + company_key: str + name: str + status: str + + +class MemberItem(BaseModel): + id: str + authentik_sub: str + email: str | None = None + display_name: str | None = None + is_active: bool + + +class ListResponse(BaseModel): + items: list + total: int + limit: int + offset: int + + +class PermissionGroupCreateRequest(BaseModel): + group_key: str + name: str + status: str = "active" + + +class PermissionGroupItem(BaseModel): + id: str + group_key: str + name: str + status: str diff --git a/backend/app/schemas/members.py b/backend/app/schemas/members.py deleted file mode 100644 index c275ef3..0000000 --- a/backend/app/schemas/members.py +++ /dev/null @@ -1,37 +0,0 @@ -from pydantic import BaseModel - -from app.schemas.organizations import OrganizationSummary - - -class MemberCreateRequest(BaseModel): - authentik_sub: str - email: str | None = None - display_name: str | None = None - is_active: bool = True - - -class MemberUpdateRequest(BaseModel): - email: str | None = None - display_name: str | None = None - is_active: bool | None = None - - -class MemberSummary(BaseModel): - id: str - authentik_sub: str - authentik_user_id: int | None = None - email: str | None = None - display_name: str | None = None - is_active: bool - - -class MemberListResponse(BaseModel): - items: list[MemberSummary] - total: int - limit: int - offset: int - - -class MemberOrganizationsResponse(BaseModel): - member: MemberSummary - organizations: list[OrganizationSummary] diff --git a/backend/app/schemas/organizations.py b/backend/app/schemas/organizations.py deleted file mode 100644 index 019ac4c..0000000 --- a/backend/app/schemas/organizations.py +++ /dev/null @@ -1,29 +0,0 @@ -from pydantic import BaseModel - - -class OrganizationCreateRequest(BaseModel): - org_code: str - name: str - tax_id: str | None = None - status: str = "active" - - -class OrganizationUpdateRequest(BaseModel): - name: str | None = None - tax_id: str | None = None - status: str | None = None - - -class OrganizationSummary(BaseModel): - id: str - org_code: str - name: str - tax_id: str | None = None - status: str - - -class OrganizationListResponse(BaseModel): - items: list[OrganizationSummary] - total: int - limit: int - offset: int diff --git a/backend/app/schemas/permissions.py b/backend/app/schemas/permissions.py index a868aa3..489b9bf 100644 --- a/backend/app/schemas/permissions.py +++ b/backend/app/schemas/permissions.py @@ -7,7 +7,8 @@ class PermissionGrantRequest(BaseModel): display_name: str | None = None scope_type: str scope_id: str - module: str + system: str + module: str | None = None action: str @@ -15,13 +16,15 @@ class PermissionRevokeRequest(BaseModel): authentik_sub: str scope_type: str scope_id: str - module: str + system: str + module: str | None = None action: str class PermissionItem(BaseModel): scope_type: str scope_id: str + system: str | None = None module: str action: str diff --git a/backend/app/services/permission_service.py b/backend/app/services/permission_service.py index b2e6788..4cdceb4 100644 --- a/backend/app/services/permission_service.py +++ b/backend/app/services/permission_service.py @@ -3,11 +3,11 @@ from app.schemas.permissions import PermissionItem, PermissionSnapshotResponse class PermissionService: @staticmethod - def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str, str]]) -> PermissionSnapshotResponse: + def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str | None, str, str]]) -> PermissionSnapshotResponse: return PermissionSnapshotResponse( authentik_sub=authentik_sub, permissions=[ - PermissionItem(scope_type=s_type, scope_id=s_id, module=module, action=action) - for s_type, s_id, module, action in 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 ], ) diff --git a/backend/scripts/init_schema.sql b/backend/scripts/init_schema.sql index d87c6b4..5bbe825 100644 --- a/backend/scripts/init_schema.sql +++ b/backend/scripts/init_schema.sql @@ -2,54 +2,138 @@ 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 users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - authentik_sub VARCHAR(255) NOT NULL UNIQUE, + authentik_sub TEXT NOT NULL UNIQUE, authentik_user_id INTEGER, - email VARCHAR(320), - display_name VARCHAR(255), + email TEXT UNIQUE, + display_name TEXT, + status record_status NOT NULL DEFAULT 'active', is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS permissions ( +CREATE TABLE IF NOT EXISTS companies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - scope_type VARCHAR(32) NOT NULL, - scope_id VARCHAR(128) NOT NULL, - module VARCHAR(128) NOT NULL, - action VARCHAR(32) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_permissions_user_scope_module_action - UNIQUE (user_id, scope_type, scope_id, module, action) -); - -CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub); -CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id); - -CREATE TABLE IF NOT EXISTS organizations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - org_code VARCHAR(64) NOT NULL UNIQUE, - name VARCHAR(255) NOT NULL, - tax_id VARCHAR(32), - status VARCHAR(16) NOT NULL DEFAULT 'active', + company_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 INDEX IF NOT EXISTS idx_organizations_org_code ON organizations(org_code); -CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status); - -CREATE TABLE IF NOT EXISTS member_organizations ( +CREATE TABLE IF NOT EXISTS systems ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - member_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + system_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status record_status NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_member_organizations_member_org UNIQUE (member_id, organization_id) + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX IF NOT EXISTS idx_member_organizations_member_id ON member_organizations(member_id); -CREATE INDEX IF NOT EXISTS idx_member_organizations_org_id ON member_organizations(organization_id); +CREATE TABLE IF NOT EXISTS modules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_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 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, + status record_status NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +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 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 permission_action NOT NULL, + scope_type scope_type 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() +); + +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)) + ); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_company +ON user_scope_permissions(user_id, module_id, action, scope_type, company_id) +WHERE scope_type = 'company'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_site +ON user_scope_permissions(user_id, module_id, action, scope_type, site_id) +WHERE scope_type = 'site'; + +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, + authentik_sub TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, authentik_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 UNIQUE INDEX IF NOT EXISTS uq_pgp_group_rule +ON permission_group_permissions(group_id, system, module, action, scope_type, scope_id); + +CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub); +CREATE INDEX IF NOT EXISTS idx_sites_company_id ON sites(company_id); +CREATE INDEX IF NOT EXISTS idx_usp_user_id ON user_scope_permissions(user_id); +CREATE INDEX IF NOT EXISTS idx_usp_module_id ON user_scope_permissions(module_id); +CREATE INDEX IF NOT EXISTS idx_usp_company_id ON user_scope_permissions(company_id); +CREATE INDEX IF NOT EXISTS idx_usp_site_id ON user_scope_permissions(site_id); COMMIT; diff --git a/backend/scripts/migrate_add_org_member_tables.sql b/backend/scripts/migrate_add_org_member_tables.sql deleted file mode 100644 index 4f076aa..0000000 --- a/backend/scripts/migrate_add_org_member_tables.sql +++ /dev/null @@ -1,27 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS organizations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - org_code VARCHAR(64) NOT NULL UNIQUE, - name VARCHAR(255) NOT NULL, - tax_id VARCHAR(32), - status VARCHAR(16) NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_organizations_org_code ON organizations(org_code); -CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status); - -CREATE TABLE IF NOT EXISTS member_organizations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - member_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_member_organizations_member_org UNIQUE (member_id, organization_id) -); - -CREATE INDEX IF NOT EXISTS idx_member_organizations_member_id ON member_organizations(member_id); -CREATE INDEX IF NOT EXISTS idx_member_organizations_org_id ON member_organizations(organization_id); - -COMMIT; diff --git a/backend/scripts/migrate_align_company_site_member_system.sql b/backend/scripts/migrate_align_company_site_member_system.sql new file mode 100644 index 0000000..4df9cef --- /dev/null +++ b/backend/scripts/migrate_align_company_site_member_system.sql @@ -0,0 +1,73 @@ +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, + authentik_sub TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, authentik_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_authentik_sub ON permission_group_members(authentik_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/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index 94e6a87..de6eae3 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -1,98 +1,37 @@ -# memberapi.ose.tw 後端架構(FastAPI) +# memberapi.ose.tw 後端架構(公司/品牌站台/會員) -## 1. 目標與邊界 -- 網域:`memberapi.ose.tw` -- 角色:會員中心後端真相來源(Member + Organization + Permission) -- 範圍: - - user/member 管理(以 `authentik_sub` 為跨系統主鍵) - - organization 管理 - - member-organization 關聯管理 - - permission grant/revoke 與 snapshot - - internal 查詢 API 提供其他系統 -- 不在本服務處理: - - 前端 UI/互動流程 - - Authentik hosted login page 本身 +## 核心主檔(對齊 DB Schema) +- `users`:會員 +- `companies`:公司 +- `sites`:品牌站台(隸屬 company) +- `systems`:系統層(member/mkt/...) +- `modules`:模組(使用 `system.module` key) -## 2. 技術棧 -- Python 3.12 -- FastAPI -- SQLAlchemy 2.0 -- PostgreSQL(psycopg) -- Pydantic Settings +## 權限模型 +- 直接權限:`user_scope_permissions` +- 群組權限:`permission_groups` + `permission_group_members` + `permission_group_permissions` +- Snapshot 回傳:合併「user 直接 + group」去重 -## 3. 後端目錄 -- `backend/app/main.py` -- `backend/app/api/` - - `auth.py` - - `me.py` - - `admin.py`(permission) - - `admin_entities.py`(member/org) - - `internal.py` - - `internal_entities.py` -- `backend/app/models/` - - `user.py` - - `permission.py` - - `organization.py` - - `member_organization.py` - - `api_client.py` -- `backend/app/repositories/` - - `users_repo.py` - - `permissions_repo.py` - - `organizations_repo.py` - - `member_organizations_repo.py` +## 授權層級 +- `system` 必填 +- `module` 選填 + - 有值:`{system}.{module}`(例:`mkt.campaign`) + - 無值:系統層權限,使用 `system.__system__` -## 4. 資料模型 -- `users` - - `id`, `authentik_sub`(unique), `authentik_user_id`, `email`, `display_name`, `is_active`, timestamps -- `permissions` - - `id`, `user_id`, `scope_type`, `scope_id`, `module`, `action`, `created_at` - - unique: `(user_id, scope_type, scope_id, module, action)` -- `organizations` - - `id`, `org_code`(unique), `name`, `tax_id`, `status`, timestamps -- `member_organizations` - - `id`, `member_id`, `organization_id`, `created_at` - - unique: `(member_id, organization_id)` -- `api_clients`(由 `docs/API_CLIENTS_SQL.sql` 建立) - - `client_key`, `api_key_hash`, `status`, allowlist, expires/rate-limit 欄位 +## 主要 API +- `GET /me` +- `GET /me/permissions/snapshot` +- `POST /admin/permissions/grant|revoke` +- `GET|POST /admin/systems` +- `GET|POST /admin/modules` +- `GET|POST /admin/companies` +- `GET|POST /admin/sites` +- `GET /admin/members` +- `GET|POST /admin/permission-groups` +- `POST|DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}` +- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke` +- `GET /internal/systems|modules|companies|sites|members` -## 5. API 設計 -- 健康檢查 - - `GET /healthz` -- 登入 - - `GET /auth/oidc/url` - - `POST /auth/oidc/exchange` -- 使用者路由(Bearer) - - `GET /me` - - `GET /me/permissions/snapshot` -- 管理路由(`X-Client-Key` + `X-API-Key`) - - `POST /admin/permissions/grant` - - `POST /admin/permissions/revoke` - - `GET|POST|PATCH /admin/organizations...` - - `GET|POST|PATCH /admin/members...` - - `GET|POST|DELETE /admin/members/{member_id}/organizations...` -- 內部路由(`X-Internal-Secret`) - - `POST /internal/users/upsert-by-sub` - - `GET /internal/permissions/{authentik_sub}/snapshot` - - `POST /internal/authentik/users/ensure` - - `GET /internal/members` - - `GET /internal/members/by-sub/{authentik_sub}` - - `GET /internal/organizations` - - `GET /internal/organizations/by-code/{org_code}` - - `GET /internal/members/{member_id}/organizations` - -## 6. 安全策略 -- `admin` 路由:API client 驗證(status/過期/hash/allowlist) -- `internal` 路由:`X-Internal-Secret` -- `me` 路由:Auth token + JWKS 驗簽 -- `/me` 補值:若 token 無 `email/name`,會呼叫 `userinfo` 補齊 - -## 7. 與其他系統資料流 -1. 其他系統登入後,以 token `sub` 呼叫 `/internal/users/upsert-by-sub` -2. 需要組織/會員資料時,走 `/internal/members*`、`/internal/organizations*` -3. 權限查詢走 `/internal/permissions/{sub}/snapshot` -4. 後台人員調整權限走 `/admin/permissions/grant|revoke` - -## 8. 下一階段建議 -- 導入 Alembic migration -- 加 audit log(誰在何時做了 member/org/permission 變更) -- 補上整合測試與 rate-limit metrics +## DB Migration +- 初始化:`backend/scripts/init_schema.sql` +- 舊庫補齊:`backend/scripts/migrate_align_company_site_member_system.sql` diff --git a/docs/FRONTEND_API_CONTRACT.md b/docs/FRONTEND_API_CONTRACT.md index 1b58ca2..6aee9a3 100644 --- a/docs/FRONTEND_API_CONTRACT.md +++ b/docs/FRONTEND_API_CONTRACT.md @@ -2,229 +2,99 @@ Base URL:`https://memberapi.ose.tw` -## 0. OIDC 登入(目前主流程) -### GET `/auth/oidc/url?redirect_uri=` -200 Response: -```json -{ - "authorize_url": "https://auth.ose.tw/application/o/authorize/..." -} -``` - -### POST `/auth/oidc/exchange` -Request: -```json -{ - "code": "authorization-code", - "redirect_uri": "http://localhost:5173/auth/callback" -} -``` - -200 Response: -```json -{ - "access_token": "", - "token_type": "Bearer", - "expires_in": 3600, - "scope": "openid profile email" -} -``` +## 0. OIDC 登入 +- `GET /auth/oidc/url?redirect_uri=...` +- `POST /auth/oidc/exchange` ## 1. 使用者資訊 -### GET `/me` -Headers: -- `Authorization: Bearer ` +- `GET /me` +- `GET /me/permissions/snapshot` -200 Response: +`permissions` item: ```json { - "sub": "authentik-sub-123", - "email": "user@example.com", - "display_name": "User Name" + "scope_type": "company|site", + "scope_id": "company_key_or_site_key", + "system": "mkt", + "module": "mkt.campaign", + "action": "view" } ``` -401 Error: -```json -{ "detail": "missing_bearer_token" } -``` -或 -```json -{ "detail": "invalid_bearer_token" } -``` - -## 2. 我的權限快照 -### GET `/me/permissions/snapshot` +## 2. 權限(User 直接授權) Headers: -- `Authorization: Bearer ` +- `X-Client-Key` +- `X-API-Key` -200 Response: -```json -{ - "authentik_sub": "authentik-sub-123", - "permissions": [ - { - "scope_type": "site", - "scope_id": "tw-main", - "module": "campaign", - "action": "view" - } - ] -} -``` - -## 3. Grant 權限 ### POST `/admin/permissions/grant` -Headers: -- `X-Client-Key: ` -- `X-API-Key: ` - -Request: ```json { - "authentik_sub": "authentik-sub-123", + "authentik_sub": "authentik-sub", "email": "user@example.com", - "display_name": "User Name", - "scope_type": "site", - "scope_id": "tw-main", + "display_name": "User", + "scope_type": "company", + "scope_id": "ose-main", + "system": "mkt", "module": "campaign", "action": "view" } ``` -200 Response: -```json -{ - "permission_id": "uuid", - "result": "granted" -} -``` - -## 4. Revoke 權限 ### POST `/admin/permissions/revoke` -Headers: -- `X-Client-Key: ` -- `X-API-Key: ` - -Request: ```json { - "authentik_sub": "authentik-sub-123", + "authentik_sub": "authentik-sub", "scope_type": "site", "scope_id": "tw-main", + "system": "mkt", "module": "campaign", "action": "view" } ``` -200 Response: -```json -{ - "deleted": 1, - "result": "revoked" -} -``` +說明: +- `module` 可省略,代表系統層權限,後端會使用 `system.__system__`。 +- `module` 有值時會組成 `{system}.{module}` 存入(例如 `mkt.campaign`)。 -404 Response: -```json -{ "detail": "user_not_found" } -``` - -## 5. Health Check -### GET `/healthz` -200 Response: -```json -{ "status": "ok" } -``` - -## 6. 組織管理(admin) -### GET `/admin/organizations` +## 3. 主資料管理(admin) Headers: -- `X-Client-Key: ` -- `X-API-Key: ` +- `X-Client-Key` +- `X-API-Key` -Query: -- `keyword` (optional) -- `status` (optional: `active|inactive`) -- `limit` (default `50`) -- `offset` (default `0`) +- `GET/POST /admin/systems` +- `GET/POST /admin/modules` +- `GET/POST /admin/companies` +- `GET/POST /admin/sites` +- `GET /admin/members` -### POST `/admin/organizations` -```json -{ - "org_code": "ose-main", - "name": "Ose Main", - "tax_id": "12345678", - "status": "active" -} -``` - -### PATCH `/admin/organizations/{org_id}` -```json -{ - "name": "Ose Main Updated", - "status": "inactive" -} -``` - -### POST `/admin/organizations/{org_id}/activate` -### POST `/admin/organizations/{org_id}/deactivate` - -## 7. 會員管理(admin) -### GET `/admin/members` +## 4. 權限群組(一組權限綁多個 user) Headers: -- `X-Client-Key: ` -- `X-API-Key: ` +- `X-Client-Key` +- `X-API-Key` -Query: -- `keyword` (optional) -- `is_active` (optional: `true|false`) -- `limit` (default `50`) -- `offset` (default `0`) +- `GET/POST /admin/permission-groups` +- `POST /admin/permission-groups/{group_key}/members/{authentik_sub}` +- `DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}` +- `POST /admin/permission-groups/{group_key}/permissions/grant` +- `POST /admin/permission-groups/{group_key}/permissions/revoke` -### POST `/admin/members` -```json -{ - "authentik_sub": "authentik-sub-123", - "email": "user@example.com", - "display_name": "User Name", - "is_active": true -} -``` +群組授權 payload 與 user 授權 payload 相同(用 `system/module/scope/action`)。 -### PATCH `/admin/members/{member_id}` -```json -{ - "display_name": "New Name", - "is_active": false -} -``` - -### POST `/admin/members/{member_id}/activate` -### POST `/admin/members/{member_id}/deactivate` - -## 8. 會員/組織關聯(admin) -### GET `/admin/members/{member_id}/organizations` -### POST `/admin/members/{member_id}/organizations/{org_id}` -### DELETE `/admin/members/{member_id}/organizations/{org_id}` - -## 9. 系統對系統查詢(internal) +## 5. Internal 查詢 API(其他系統) Headers: -- `X-Internal-Secret: ` +- `X-Internal-Secret` -Endpoints: +- `GET /internal/systems` +- `GET /internal/modules` +- `GET /internal/companies` +- `GET /internal/sites` - `GET /internal/members` -- `GET /internal/members/by-sub/{authentik_sub}` -- `GET /internal/organizations` -- `GET /internal/organizations/by-code/{org_code}` -- `GET /internal/members/{member_id}/organizations` +- `GET /internal/permissions/{authentik_sub}/snapshot` -## 10. 常見錯誤碼 +## 6. 常見錯誤 - `401 invalid_client` - `401 invalid_api_key` -- `401 client_expired` -- `403 origin_not_allowed` -- `403 ip_not_allowed` -- `403 path_not_allowed` -- `503 internal_secret_not_configured` -- `503 authentik_admin_not_configured` +- `401 invalid_internal_secret` +- `404 system_not_found` +- `404 company_not_found` +- `404 site_not_found`