Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed *
Empty file.
7 changes: 4 additions & 3 deletions api/src/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
class Resource(StrEnum):
ADMIN = "admin"
ORG_MANAGER = "org_manager"
CONTENT_MANAGER = "content-manager"
CONTENT_MANAGER = "content_manager"


class Scope(StrEnum):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}'",
Expand Down
41 changes: 28 additions & 13 deletions api/src/routers/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
56 changes: 32 additions & 24 deletions api/src/services/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -139,51 +140,58 @@ 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:
"""Partial update (PATCH) — only sets fields that are present in the payload."""
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:
"""Hard-delete a module document."""
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})
16 changes: 16 additions & 0 deletions api/src/services/platform_admin/realm_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 2 additions & 0 deletions api/src/services/sending_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
Loading
Loading