diff --git a/* b/* deleted file mode 100644 index e69de29b..00000000 diff --git a/api/src/core/security.py b/api/src/core/security.py index 9e6947da..68f55d06 100644 --- a/api/src/core/security.py +++ b/api/src/core/security.py @@ -20,7 +20,7 @@ class Resource(StrEnum): ADMIN = "admin" ORG_MANAGER = "org_manager" - CONTENT_MANAGER = "content-manager" + CONTENT_MANAGER = "content_manager" class Scope(StrEnum): @@ -94,8 +94,8 @@ def _get_required_roles(self) -> set[str]: (Resource.ADMIN.value, Scope.MANAGE.value): {"admin"}, (Resource.ORG_MANAGER.value, Scope.VIEW.value): {"org_manager"}, (Resource.ORG_MANAGER.value, Scope.MANAGE.value): {"org_manager"}, - (Resource.CONTENT_MANAGER.value, Scope.VIEW.value): {"content-manager"}, - (Resource.CONTENT_MANAGER.value, Scope.MANAGE.value): {"content-manager"}, + (Resource.CONTENT_MANAGER.value, Scope.VIEW.value): {"content_manager"}, + (Resource.CONTENT_MANAGER.value, Scope.MANAGE.value): {"content_manager"}, } required_roles: set[str] = set() @@ -132,6 +132,7 @@ async def __call__( if required_roles.isdisjoint(token_roles): permission_label = f"{' or '.join(self.resources)}#{self.scope}" + logging.error(f"403 ERROR: Token Roles: {token_roles}, Required: {required_roles}, Payload realm_access: {payload.get('realm_access')} resource_access: {payload.get('resource_access')}") raise HTTPException( status_code=403, detail=f"Permission denied for '{permission_label}'", diff --git a/api/src/routers/templates.py b/api/src/routers/templates.py index 027c097b..51e91053 100644 --- a/api/src/routers/templates.py +++ b/api/src/routers/templates.py @@ -3,31 +3,46 @@ from fastapi import APIRouter, Depends, status from src.core.security import Roles, Resource, Scope +from src.core.dependencies import CurrentRealm, OAuth2Scheme +from src.services.compliance.token_helpers import decode_token_verified from src.services import templates as template_service from src.services.templates import TemplateCreate, TemplateUpdate, TemplateOut router = APIRouter() +def get_org_context(token: str, realm: str) -> tuple[str, bool]: + claims = decode_token_verified(token) + roles = claims.get("realm_access", {}).get("roles", []) + is_content_manager = "content_manager" in roles + org_id = "platform" if is_content_manager else realm + return org_id, is_content_manager + + @router.get("/templates", dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.VIEW))]) -async def list_templates() -> List[TemplateOut]: - return await template_service.list_templates() +async def list_templates(token: OAuth2Scheme, realm: CurrentRealm) -> List[TemplateOut]: + org_id, is_content_manager = get_org_context(token, realm) + return await template_service.list_templates(org_id=org_id, include_platform=not is_content_manager) @router.get("/templates/{template_id}", dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.VIEW))]) -async def get_template(template_id: str) -> TemplateOut: - return await template_service.get_template(template_id) +async def get_template(template_id: str, token: OAuth2Scheme, realm: CurrentRealm) -> TemplateOut: + org_id, is_content_manager = get_org_context(token, realm) + return await template_service.get_template(template_id, org_id=org_id, can_view_platform=not is_content_manager) -@router.post("/templates", status_code=status.HTTP_201_CREATED, dependencies=[Depends(Roles(Resource.CONTENT_MANAGER, Scope.MANAGE))]) -async def create_template(template: TemplateCreate) -> TemplateOut: - return await template_service.create_template(template) +@router.post("/templates", status_code=status.HTTP_201_CREATED, dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.MANAGE))]) +async def create_template(template: TemplateCreate, token: OAuth2Scheme, realm: CurrentRealm) -> TemplateOut: + org_id, _ = get_org_context(token, realm) + return await template_service.create_template(template, org_id=org_id) -@router.put("/templates/{template_id}", dependencies=[Depends(Roles(Resource.CONTENT_MANAGER, Scope.MANAGE))]) -async def update_template(template_id: str, template: TemplateUpdate) -> TemplateOut: - return await template_service.update_template(template_id, template) +@router.put("/templates/{template_id}", dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.MANAGE))]) +async def update_template(template_id: str, template: TemplateUpdate, token: OAuth2Scheme, realm: CurrentRealm) -> TemplateOut: + org_id, _ = get_org_context(token, realm) + return await template_service.update_template(template_id, template, org_id=org_id) -@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(Roles(Resource.CONTENT_MANAGER, Scope.MANAGE))]) -async def delete_template(template_id: str) -> None: - await template_service.delete_template(template_id) +@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.MANAGE))]) +async def delete_template(template_id: str, token: OAuth2Scheme, realm: CurrentRealm) -> None: + org_id, _ = get_org_context(token, realm) + await template_service.delete_template(template_id, org_id=org_id) diff --git a/api/src/services/modules.py b/api/src/services/modules.py index 6e43360d..1664b81a 100644 --- a/api/src/services/modules.py +++ b/api/src/services/modules.py @@ -15,6 +15,7 @@ from bson import ObjectId from bson.errors import InvalidId +from pymongo import ReturnDocument from fastapi import HTTPException, status from src.core.mongo import get_modules_collection @@ -139,19 +140,23 @@ async def update_module(module_id: str, payload: ModuleUpdate, realm: str) -> Mo oid = _to_object_id(module_id) col = get_modules_collection() - existing = await col.find_one({"_id": oid, "realm": realm}) - if existing is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND) - data = payload.model_dump() - data.update( - updated_at=_now(), - version=existing.get("version", 1) + 1, + # We must explicitly look up existing version or default to 1 for atomic bump? + # Actually wait, we can do $inc for version! Let's just do it directly. + data["updated_at"] = _now() + + updated = await col.find_one_and_update( + {"_id": oid, "realm": realm}, + { + "$set": data, + "$inc": {"version": 1} + }, + return_document=ReturnDocument.AFTER ) + if updated is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND) - await col.update_one({"_id": oid}, {"$set": data}) - updated = await col.find_one({"_id": oid}) - return _doc_to_out(updated) # type: ignore[arg-type] + return _doc_to_out(updated) async def patch_module(module_id: str, payload: ModulePatch, realm: str) -> ModuleOut: @@ -159,22 +164,27 @@ async def patch_module(module_id: str, payload: ModulePatch, realm: str) -> Modu oid = _to_object_id(module_id) col = get_modules_collection() - existing = await col.find_one({"_id": oid, "realm": realm}) - if existing is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND) - - # Only include fields that were explicitly provided (exclude_unset) delta = payload.model_dump(exclude_unset=True) if not delta: - # Nothing to change — return as-is to avoid a spurious DB round-trip + existing = await col.find_one({"_id": oid, "realm": realm}) + if existing is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND) return _doc_to_out(existing) delta["updated_at"] = _now() - delta["version"] = existing.get("version", 1) + 1 - await col.update_one({"_id": oid}, {"$set": delta}) - updated = await col.find_one({"_id": oid}) - return _doc_to_out(updated) # type: ignore[arg-type] + updated = await col.find_one_and_update( + {"_id": oid, "realm": realm}, + { + "$set": delta, + "$inc": {"version": 1} + }, + return_document=ReturnDocument.AFTER + ) + if updated is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND) + + return _doc_to_out(updated) async def delete_module(module_id: str, realm: str) -> None: @@ -182,8 +192,6 @@ async def delete_module(module_id: str, realm: str) -> None: oid = _to_object_id(module_id) col = get_modules_collection() - existing = await col.find_one({"_id": oid, "realm": realm}) - if existing is None: + deleted = await col.find_one_and_delete({"_id": oid, "realm": realm}) + if deleted is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND) - - await col.delete_one({"_id": oid}) diff --git a/api/src/services/platform_admin/realm_handler.py b/api/src/services/platform_admin/realm_handler.py index c14a1912..618a259b 100644 --- a/api/src/services/platform_admin/realm_handler.py +++ b/api/src/services/platform_admin/realm_handler.py @@ -46,6 +46,22 @@ def create_realm_in_keycloak(self, realm: RealmCreate, session: Session) -> Real features=realm.features, ) + try: + token = self.admin._get_admin_token() + users = self.admin.list_users(realm.name) + org_manager_user = next((u for u in users if str(u.get("username")).lower() == "org_manager"), None) + if org_manager_user: + client = self.admin.keycloak_client.get_client_by_client_id(realm.name, token, "realm-management") + if client: + client_id = client.get("id") + client_role = self.admin.keycloak_client.get_client_role(realm.name, token, client_id, "realm-admin") + if client_role: + self.admin.keycloak_client.assign_client_roles( + realm.name, token, org_manager_user.get("id"), client_id, [client_role] + ) + except Exception as e: + print(f"Warning: Failed to bootstrap ORG_MANAGER with realm-admin: {e}") + self._ensure_realm(session, realm.name, normalized_domain) diff --git a/api/src/services/sending_profile.py b/api/src/services/sending_profile.py index 1f846432..5fbb73fa 100644 --- a/api/src/services/sending_profile.py +++ b/api/src/services/sending_profile.py @@ -57,6 +57,8 @@ def test_sending_profile_configuration(self, profile: SendingProfileCreate) -> s raise ValueError("Server disconnected unexpectedly") except TimeoutError: raise ValueError("Connection timed out") + except OSError as e: + raise ValueError(f"Network error: {e}") except Exception as e: raise RuntimeError(str(e)) diff --git a/api/src/services/templates.py b/api/src/services/templates.py index 83f88ca3..45560d79 100644 --- a/api/src/services/templates.py +++ b/api/src/services/templates.py @@ -3,7 +3,9 @@ from typing import Optional, List, Literal, Dict, Any from fastapi import HTTPException, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +from pymongo import ReturnDocument from src.core.mongo import get_templates_collection, serialize_document, to_object_id @@ -31,14 +33,29 @@ def replace(match: re.Match) -> str: return pattern.sub(replace, html) -class TemplateCreate(BaseModel): +class TemplateBase(BaseModel): name: str = Field(..., description="Template display name") path: PathLiteral = Field(..., description="Content path to organize templates") - subject: str = Field(..., description="Email subject line") + subject: Optional[str] = Field(None, description="Email subject line") category: Optional[str] = Field(None, description="Grouping, e.g. 'finance'") description: Optional[str] = Field(None, description="Short description for UI") html: str = Field(..., description="HTML content of the phishing template") +class TemplateCreate(TemplateBase): + + @model_validator(mode='after') + def validate_template_requirements(self) -> 'TemplateCreate': + if self.path == "/templates/emails/": + if not self.subject or not self.subject.strip(): + raise ValueError("Email templates must have a subject line.") + if "${{redirect}}" not in self.html or "${{pixel}}" not in self.html: + raise ValueError("Email templates must include both ${{redirect}} and ${{pixel}}.") + elif self.path == "/templates/pages/": + if "${{redirect}}" not in self.html: + raise ValueError("Landing page templates must include the ${{redirect}} variable.") + self.subject = None + return self + class TemplateUpdate(BaseModel): name: Optional[str] = None @@ -48,23 +65,51 @@ class TemplateUpdate(BaseModel): description: Optional[str] = None html: Optional[str] = None - -class TemplateOut(TemplateCreate): + @model_validator(mode='after') + def validate_template_requirements(self) -> 'TemplateUpdate': + if self.path == "/templates/emails/": + if self.subject is not None and not self.subject.strip(): + raise ValueError("Email templates must have a valid subject line.") + if self.html is not None: + if "${{redirect}}" not in self.html or "${{pixel}}" not in self.html: + raise ValueError("Email templates must include both ${{redirect}} and ${{pixel}}.") + elif self.path == "/templates/pages/": + if self.html is not None and "${{redirect}}" not in self.html: + raise ValueError("Landing page templates must include the ${{redirect}} variable.") + self.subject = None + return self + + +class TemplateOut(TemplateBase): id: str + org_id: str = "platform" created_at: datetime updated_at: datetime -async def list_templates() -> List[TemplateOut]: +async def list_templates(org_id: str, include_platform: bool = False) -> List[TemplateOut]: collection = get_templates_collection() - cursor = collection.find().sort("updated_at", -1) + + platform_conds = [{"org_id": "platform"}, {"org_id": {"$exists": False}}, {"org_id": None}] + + if org_id == "platform": + query = {"$or": platform_conds} + else: + query = {"org_id": org_id} + if include_platform: + query = {"$or": [{"org_id": org_id}] + platform_conds} + + cursor = collection.find(query).sort("updated_at", -1) results: List[TemplateOut] = [] async for doc in cursor: - results.append(TemplateOut.model_validate(serialize_document(doc))) + doc_dict = serialize_document(doc) + if "org_id" not in doc_dict or not doc_dict["org_id"]: + doc_dict["org_id"] = "platform" + results.append(TemplateOut.model_validate(doc_dict)) return results -async def get_template(template_id: str) -> TemplateOut: +async def get_template(template_id: str, org_id: str, can_view_platform: bool = False) -> TemplateOut: collection = get_templates_collection() try: oid = to_object_id(template_id) @@ -74,13 +119,23 @@ async def get_template(template_id: str) -> TemplateOut: doc = await collection.find_one({"_id": oid}) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - return TemplateOut.model_validate(serialize_document(doc)) + + doc_dict = serialize_document(doc) + doc_org_id = doc_dict.get("org_id") or "platform" + doc_dict["org_id"] = doc_org_id + + if doc_org_id != org_id: + if not (can_view_platform and doc_org_id == "platform"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to view this template") + return TemplateOut.model_validate(doc_dict) -async def create_template(template: TemplateCreate) -> TemplateOut: + +async def create_template(template: TemplateCreate, org_id: str) -> TemplateOut: collection = get_templates_collection() now = datetime.utcnow() payload = template.model_dump() + payload["org_id"] = org_id payload["created_at"] = now payload["updated_at"] = now @@ -88,39 +143,65 @@ async def create_template(template: TemplateCreate) -> TemplateOut: doc = await collection.find_one({"_id": result.inserted_id}) if not doc: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create template") - return TemplateOut.model_validate(serialize_document(doc)) + + doc_dict = serialize_document(doc) + doc_dict["org_id"] = org_id + return TemplateOut.model_validate(doc_dict) -async def update_template(template_id: str, template: TemplateUpdate) -> TemplateOut: +async def update_template(template_id: str, template: TemplateUpdate, org_id: str) -> TemplateOut: collection = get_templates_collection() try: oid = to_object_id(template_id) except ValueError: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid template id") - + payload = {k: v for k, v in template.model_dump().items() if v is not None} if not payload: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update") payload["updated_at"] = datetime.utcnow() - result = await collection.update_one({"_id": oid}, {"$set": payload}) - if result.matched_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - - doc = await collection.find_one({"_id": oid}) + filter_q: Dict[str, Any] = {"_id": oid} + if org_id == "platform": + filter_q["$or"] = [{"org_id": "platform"}, {"org_id": {"$exists": False}}, {"org_id": None}] + else: + filter_q["org_id"] = org_id + + doc = await collection.find_one_and_update( + filter_q, + {"$set": payload}, + return_document=ReturnDocument.AFTER + ) + if not doc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - return TemplateOut.model_validate(serialize_document(doc)) + # Fallback to check if it's 404 or 403 + existing = await collection.find_one({"_id": oid}) + if not existing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to edit this template") + + doc_dict = serialize_document(doc) + doc_dict["org_id"] = doc_dict.get("org_id") or "platform" + return TemplateOut.model_validate(doc_dict) -async def delete_template(template_id: str) -> None: +async def delete_template(template_id: str, org_id: str) -> None: collection = get_templates_collection() try: oid = to_object_id(template_id) except ValueError: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid template id") - result = await collection.delete_one({"_id": oid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + filter_q: Dict[str, Any] = {"_id": oid} + if org_id == "platform": + filter_q["$or"] = [{"org_id": "platform"}, {"org_id": {"$exists": False}}, {"org_id": None}] + else: + filter_q["org_id"] = org_id + + deleted = await collection.find_one_and_delete(filter_q) + if not deleted: + existing = await collection.find_one({"_id": oid}) + if not existing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to delete this template") diff --git a/deployment/docker-compose.dev.yml b/deployment/docker-compose.dev.yml index 3f142125..ea8b6010 100644 --- a/deployment/docker-compose.dev.yml +++ b/deployment/docker-compose.dev.yml @@ -10,7 +10,7 @@ services: ports: - "5432:5432" volumes: - - db_data:/var/lib/postgresql + - db_data:/var/lib/postgresql/data - ../db/init-dbs.sql:/docker-entrypoint-initdb.d/init-dbs.sql healthcheck: @@ -136,6 +136,7 @@ services: CLIENT_SECRET: ${CLIENT_SECRET} WEB_URL: ${WEB_URL} API_URL: ${API_URL} + JAVA_OPTS_APPEND: "-Dkeycloak.migration.strategy=IGNORE_EXISTING" healthcheck: test: @@ -159,6 +160,7 @@ services: - ../keycloak/themes:/opt/keycloak/themes:ro - ../keycloak/imports:/opt/keycloak/data/import:ro - ../keycloak/providers:/opt/keycloak/providers:ro + - keycloak_data:/opt/keycloak/data api: build: @@ -240,3 +242,4 @@ volumes: rabbitmq_data: mongo_data: garage_data: + keycloak_data: diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 195484be..e65a823e 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -36,7 +36,7 @@ services: expose: - "5432" volumes: - - db_data:/var/lib/postgresql + - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s @@ -160,6 +160,7 @@ services: KC_DB_URL: jdbc:postgresql://db:5432/keycloak KC_DB_USERNAME: ${POSTGRES_USER} KC_DB_PASSWORD: ${POSTGRES_PASSWORD} + CLIENT_SECRET: ${CLIENT_SECRET} KC_HTTP_ENABLED: "true" KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "false" @@ -168,9 +169,9 @@ services: KC_HOSTNAME_STRICT: "false" KC_HEALTH_ENABLED: "true" - CLIENT_SECRET: ${CLIENT_SECRET} WEB_URL: ${WEB_URL} API_URL: ${API_URL} + JAVA_OPTS_APPEND: "-Dkeycloak.migration.strategy=IGNORE_EXISTING" healthcheck: test: ["CMD", "true"] interval: 30s @@ -183,6 +184,9 @@ services: db: condition: service_healthy + volumes: + - keycloak_data:/opt/keycloak/data + api: build: context: ../api @@ -270,3 +274,4 @@ volumes: garage_data: rabbitmq_data: mongo_data: + keycloak_data: diff --git a/keycloak/imports/realm-export.json b/keycloak/imports/realm-export.json index 603ac179..0e83a54b 100644 --- a/keycloak/imports/realm-export.json +++ b/keycloak/imports/realm-export.json @@ -80,6 +80,7 @@ "enabled": true, "publicClient": true, "redirectUris": [ + "${WEB_URL}/*", "${WEB_URL}/admin/*", "${WEB_URL}/content-manager/*" ], diff --git a/web/src/Pages/templates.tsx b/web/src/Pages/templates.tsx index 9bef8d8d..6b1a75c0 100644 --- a/web/src/Pages/templates.tsx +++ b/web/src/Pages/templates.tsx @@ -1,13 +1,15 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus, Eye, Layout } from "lucide-react"; -import { apiClient } from "@/lib/api-client"; +import { Plus } from "lucide-react"; +import { toast } from "sonner"; +import { templateApi } from "@/services/templateApi"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import DisplayModeToggle from "@/components/shared/DisplayModeToggle"; import RefreshButton from "@/components/shared/RefreshButton"; import SearchBar from "@/components/shared/SearchBar"; +import { TemplateCard } from "@/components/templates/TemplateCard"; import { TemplateFormModal } from "@/components/templates/TemplateFormModal"; import { TemplatePreviewModal } from "@/components/templates/TemplatePreviewModal"; import { LoadingGrid } from "@/components/templates/LoadingGrid"; @@ -22,7 +24,6 @@ type ViewMode = "grid" | "table"; const templateMatchesQuery = (template: Template, query: string) => { const haystack = [ template.name, - template.subject, template.description, template.category ] @@ -38,13 +39,14 @@ export default function TemplatesPage() { Template[] >({ queryKey: ["templates"], - queryFn: () => apiClient.get("/templates"), + queryFn: () => templateApi.getTemplates(), staleTime: 30_000 }); const qc = useQueryClient(); const [previewId, setPreviewId] = useState(null); const [showForm, setShowForm] = useState(false); + const [editingTemplate, setEditingTemplate] = useState