diff --git a/README.md b/README.md index 7052551..e94dcd7 100644 --- a/README.md +++ b/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/app/api/admin.py b/app/api/admin.py index 947ed36..5be9951 100644 --- a/app/api/admin.py +++ b/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/app/api/admin_catalog.py b/app/api/admin_catalog.py new file mode 100644 index 0000000..7eb71e1 --- /dev/null +++ b/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/app/api/admin_entities.py b/app/api/admin_entities.py deleted file mode 100644 index 89c1531..0000000 --- a/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/app/api/internal.py b/app/api/internal.py index 903d194..8e65066 100644 --- a/app/api/internal.py +++ b/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/app/api/internal_catalog.py b/app/api/internal_catalog.py new file mode 100644 index 0000000..e8b4dd5 --- /dev/null +++ b/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/app/api/internal_entities.py b/app/api/internal_entities.py deleted file mode 100644 index b728341..0000000 --- a/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/app/api/me.py b/app/api/me.py index ac72227..05285fa 100644 --- a/app/api/me.py +++ b/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/app/main.py b/app/main.py index e8ef733..48d4624 100644 --- a/app/main.py +++ b/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/app/models/__init__.py b/app/models/__init__.py index 44a7f90..235653c 100644 --- a/app/models/__init__.py +++ b/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/app/models/organization.py b/app/models/company.py similarity index 77% rename from app/models/organization.py rename to app/models/company.py index 12ee320..f0081b3 100644 --- a/app/models/organization.py +++ b/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/app/models/member_organization.py b/app/models/member_organization.py deleted file mode 100644 index 19ec119..0000000 --- a/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/app/models/module.py b/app/models/module.py new file mode 100644 index 0000000..cc8ca2f --- /dev/null +++ b/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/app/models/permission_group.py b/app/models/permission_group.py new file mode 100644 index 0000000..f7f5a5c --- /dev/null +++ b/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/app/models/permission_group_member.py b/app/models/permission_group_member.py new file mode 100644 index 0000000..1f58494 --- /dev/null +++ b/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/app/models/permission_group_permission.py b/app/models/permission_group_permission.py new file mode 100644 index 0000000..0d819e0 --- /dev/null +++ b/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/app/models/site.py b/app/models/site.py new file mode 100644 index 0000000..eafdd2d --- /dev/null +++ b/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/app/models/system.py b/app/models/system.py new file mode 100644 index 0000000..c5ee6ae --- /dev/null +++ b/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/app/models/user_scope_permission.py b/app/models/user_scope_permission.py new file mode 100644 index 0000000..08f243f --- /dev/null +++ b/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/app/repositories/companies_repo.py b/app/repositories/companies_repo.py new file mode 100644 index 0000000..47628f2 --- /dev/null +++ b/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/app/repositories/member_organizations_repo.py b/app/repositories/member_organizations_repo.py deleted file mode 100644 index d9345a0..0000000 --- a/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/app/repositories/modules_repo.py b/app/repositories/modules_repo.py new file mode 100644 index 0000000..b457266 --- /dev/null +++ b/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/app/repositories/organizations_repo.py b/app/repositories/organizations_repo.py deleted file mode 100644 index 627d8cc..0000000 --- a/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/app/repositories/permission_groups_repo.py b/app/repositories/permission_groups_repo.py new file mode 100644 index 0000000..a1fa6e4 --- /dev/null +++ b/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/app/repositories/permissions_repo.py b/app/repositories/permissions_repo.py index 82ab800..5c19b93 100644 --- a/app/repositories/permissions_repo.py +++ b/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/app/repositories/sites_repo.py b/app/repositories/sites_repo.py new file mode 100644 index 0000000..d3e58c4 --- /dev/null +++ b/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/app/repositories/systems_repo.py b/app/repositories/systems_repo.py new file mode 100644 index 0000000..0464fea --- /dev/null +++ b/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/app/schemas/catalog.py b/app/schemas/catalog.py new file mode 100644 index 0000000..90c0d98 --- /dev/null +++ b/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/app/schemas/members.py b/app/schemas/members.py deleted file mode 100644 index c275ef3..0000000 --- a/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/app/schemas/organizations.py b/app/schemas/organizations.py deleted file mode 100644 index 019ac4c..0000000 --- a/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/app/schemas/permissions.py b/app/schemas/permissions.py index a868aa3..489b9bf 100644 --- a/app/schemas/permissions.py +++ b/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/app/services/permission_service.py b/app/services/permission_service.py index b2e6788..4cdceb4 100644 --- a/app/services/permission_service.py +++ b/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/scripts/init_schema.sql b/scripts/init_schema.sql index d87c6b4..5bbe825 100644 --- a/scripts/init_schema.sql +++ b/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/scripts/migrate_add_org_member_tables.sql b/scripts/migrate_add_org_member_tables.sql deleted file mode 100644 index 4f076aa..0000000 --- a/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/scripts/migrate_align_company_site_member_system.sql b/scripts/migrate_align_company_site_member_system.sql new file mode 100644 index 0000000..4df9cef --- /dev/null +++ b/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;