Skip to content

Commit c37cf00

Browse files
committed
feat: Implement a super-admin restricted endpoint to update tenant display names in both Keycloak and the local database.
1 parent 2486409 commit c37cf00

File tree

6 files changed

+105
-196
lines changed

6 files changed

+105
-196
lines changed

extensions/m8flow-backend/src/m8flow_backend/api.yml

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,9 @@ paths:
275275
description: Tenant not found
276276

277277
put:
278-
summary: Update a tenant
279-
description: Updates tenant name and/or status. Slug cannot be updated. Cannot update DELETED tenants.
280-
operationId: m8flow_backend.routes.tenant_controller.update_tenant
278+
summary: Update tenant display name
279+
description: Updates the tenant's human-readable name in both the local database and the Keycloak realm (displayName). Restricted to 'super-admin' role. The slug and ID are immutable and cannot be changed.
280+
operationId: m8flow_backend.routes.keycloak_controller.update_tenant_name
281281
tags:
282282
- Tenant
283283
parameters:
@@ -286,24 +286,21 @@ paths:
286286
required: true
287287
schema:
288288
type: string
289-
description: Unique identifier of the tenant to update
289+
description: Unique identifier (UUID) of the tenant to update
290290
requestBody:
291291
required: true
292292
content:
293293
application/json:
294294
schema:
295295
type: object
296+
required:
297+
- name
296298
properties:
297299
name:
298300
type: string
299-
description: New name for the tenant
300-
status:
301-
type: string
302-
enum: [ACTIVE, INACTIVE, DELETED]
303-
description: New status for the tenant
301+
description: New display name for the tenant
304302
example:
305303
name: "Updated Tenant Name"
306-
status: "ACTIVE"
307304
responses:
308305
"200":
309306
description: Tenant updated successfully

extensions/m8flow-backend/src/m8flow_backend/routes/keycloak_controller.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@
1212
realm_exists,
1313
tenant_login as tenant_login_svc,
1414
tenant_login_authorization_url,
15+
update_realm,
1516
verify_admin_token,
17+
get_master_admin_token,
1618
)
1719
from sqlalchemy.exc import IntegrityError
20+
from spiffworkflow_backend.exceptions.api_error import ApiError
21+
from spiffworkflow_backend.services.authorization_service import AuthorizationService
22+
from m8flow_backend.helpers.response_helper import success_response, handle_api_errors
1823

1924
from m8flow_backend.tenancy import create_tenant_if_not_exists
2025
from m8flow_backend.models.m8flow_tenant import M8flowTenantModel
2126
from spiffworkflow_backend.models.db import db
22-
from flask import request
27+
from flask import request, g
2328

2429
logger = logging.getLogger(__name__)
2530

@@ -188,3 +193,63 @@ def delete_tenant_realm(realm_id: str) -> tuple[dict, int]:
188193
except Exception as e:
189194
logger.exception("Error deleting tenant %s", realm_id)
190195
return {"detail": str(e)}, 500
196+
197+
198+
@handle_api_errors
199+
def update_tenant_name(tenant_id: str, body: dict) -> tuple[dict, int]:
200+
"""Update a tenant's name in both Keycloak (displayName) and Postgres.
201+
Restricted to super-admin role. Uses internal master admin token.
202+
"""
203+
user = getattr(g, 'user', None)
204+
if not user:
205+
raise ApiError(error_code="not_authenticated", message="User not authenticated", status_code=401)
206+
207+
# Check for super-admin permission via m8flow.yml configuration
208+
is_authorized = AuthorizationService.user_has_permission(user, "update", request.path)
209+
210+
if not is_authorized:
211+
logger.warning(
212+
"User %s (groups: %s) attempted to update tenant %s without required permissions",
213+
user.username,
214+
[getattr(g, 'identifier', g.name) for g in getattr(user, 'groups', [])],
215+
tenant_id
216+
)
217+
raise ApiError(error_code="forbidden", message="Only super-admin can update tenant name", status_code=403)
218+
219+
new_name = body.get("name")
220+
if not new_name or not str(new_name).strip():
221+
return {"detail": "name is required"}, 400
222+
new_name = str(new_name).strip()
223+
224+
try:
225+
tenant = (
226+
db.session.query(M8flowTenantModel)
227+
.filter(M8flowTenantModel.id == tenant_id)
228+
.one_or_none()
229+
)
230+
if not tenant:
231+
return {"detail": "Tenant not found"}, 404
232+
233+
# Fetch Keycloak admin token internally
234+
admin_token = get_master_admin_token()
235+
236+
# Update Keycloak realm displayName
237+
update_realm(tenant.slug, display_name=new_name, admin_token=admin_token)
238+
239+
# Update Postgres tenant name
240+
tenant.name = new_name
241+
db.session.commit()
242+
logger.info("Updated tenant name: id=%s slug=%s to name=%s (updated by %s)",
243+
tenant_id, tenant.slug, new_name, user.username)
244+
245+
return {"message": "Tenant name updated successfully", "name": new_name}, 200
246+
247+
except requests.exceptions.HTTPError as e:
248+
status = e.response.status_code if e.response is not None else 500
249+
detail = (e.response.text or str(e))[:500] if e.response is not None else str(e)
250+
logger.warning("Keycloak update realm HTTP error: %s %s", status, detail)
251+
return {"detail": detail}, status
252+
except Exception as e:
253+
db.session.rollback()
254+
logger.exception("Error updating tenant name %s", tenant_id)
255+
return {"detail": str(e)}, 500

extensions/m8flow-backend/src/m8flow_backend/routes/tenant_controller.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -59,26 +59,3 @@ def get_all_tenants():
5959
tenants = TenantService.get_all_tenants()
6060
return success_response([_serialize_tenant(t) for t in tenants], 200)
6161

62-
@handle_api_errors
63-
def update_tenant(tenant_id, body):
64-
"""Update tenant name and status. Slug cannot be updated."""
65-
user = _require_authenticated_user()
66-
body = body or {}
67-
68-
if 'slug' in body:
69-
raise ApiError(
70-
error_code="slug_update_forbidden",
71-
message="Slug cannot be updated. It is immutable after creation.",
72-
status_code=400
73-
)
74-
75-
tenant = TenantService.update_tenant(
76-
tenant_id=tenant_id,
77-
name=body.get('name'),
78-
status_str=body.get('status'),
79-
user_id=user.username
80-
)
81-
82-
return success_response({
83-
"message": f"Tenant '{tenant.name}' has been successfully updated."
84-
}, 200)

extensions/m8flow-backend/src/m8flow_backend/services/keycloak_service.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,38 @@ def delete_realm(realm_id: str, admin_token: str | None = None) -> None:
852852
logger.info("Deleted Keycloak realm: %s", realm_id)
853853

854854

855+
def update_realm(realm_id: str, display_name: str, admin_token: str | None = None) -> None:
856+
"""Update a realm in Keycloak (specifically displayName)."""
857+
if not realm_id or not str(realm_id).strip():
858+
raise ValueError("realm_id is required")
859+
860+
if not display_name or not str(display_name).strip():
861+
raise ValueError("display_name is required")
862+
863+
if not admin_token or not str(admin_token).strip():
864+
raise ValueError("admin_token is required")
865+
866+
realm_id = str(realm_id).strip()
867+
display_name = str(display_name).strip()
868+
admin_token = str(admin_token).strip()
869+
870+
base_url = keycloak_url()
871+
872+
payload = {
873+
"realm": realm_id,
874+
"displayName": display_name
875+
}
876+
877+
r = requests.put(
878+
f"{base_url}/admin/realms/{realm_id}",
879+
json=payload,
880+
headers={"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"},
881+
timeout=30,
882+
)
883+
r.raise_for_status()
884+
logger.info("Updated Keycloak realm %s: displayName=%s", realm_id, display_name)
885+
886+
855887
def verify_admin_token(token: str) -> bool:
856888
"""
857889
Verify that the provided token is a valid admin token.

extensions/m8flow-backend/src/m8flow_backend/services/tenant_service.py

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -77,50 +77,3 @@ def get_all_tenants():
7777
status_code=500
7878
)
7979

80-
@staticmethod
81-
def update_tenant(tenant_id: str, name: str | None, status_str: str | None, user_id: str):
82-
TenantService._check_not_default_tenant(tenant_id)
83-
84-
tenant = M8flowTenantModel.query.filter_by(id=tenant_id).first()
85-
86-
if not tenant:
87-
raise ApiError(
88-
error_code="tenant_not_found",
89-
message=f"Tenant with ID '{tenant_id}' not found.",
90-
status_code=404
91-
)
92-
93-
if tenant.status == TenantStatus.DELETED:
94-
raise ApiError(
95-
error_code="tenant_deleted",
96-
message=f"Cannot update tenant with ID '{tenant_id}' because it is deleted.",
97-
status_code=400
98-
)
99-
100-
try:
101-
if name:
102-
tenant.name = name
103-
104-
if status_str:
105-
try:
106-
tenant.status = TenantStatus(status_str)
107-
except ValueError:
108-
raise ApiError(
109-
error_code="invalid_status",
110-
message=f"Invalid status value: '{status_str}'. Must be one of: ACTIVE, INACTIVE, DELETED.",
111-
status_code=400
112-
)
113-
114-
tenant.modified_by = user_id
115-
db.session.commit()
116-
return tenant
117-
except ApiError:
118-
db.session.rollback()
119-
raise
120-
except Exception as e:
121-
db.session.rollback()
122-
raise ApiError(
123-
error_code="database_error",
124-
message=f"Error updating tenant: {str(e)}",
125-
status_code=500
126-
)

extensions/m8flow-backend/tests/unit/m8flow_backend/routes/test_tenant_controller.py

Lines changed: 0 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -183,118 +183,3 @@ def test_get_all_tenants_excludes_default(self, app, mock_admin_user):
183183
tenant_ids = [t["id"] for t in data]
184184
assert "default" not in tenant_ids
185185

186-
def test_update_tenant_name_success(self, app, mock_admin_user):
187-
"""Test successfully updating tenant name."""
188-
with app.app_context():
189-
with app.test_request_context("/"):
190-
g.user = mock_admin_user
191-
192-
# Create tenant
193-
tenant = M8flowTenantModel(
194-
id="update-tenant-1",
195-
name="Original Name",
196-
slug="update-tenant",
197-
status=TenantStatus.ACTIVE,
198-
created_by="admin",
199-
modified_by="admin"
200-
)
201-
db.session.add(tenant)
202-
db.session.commit()
203-
204-
# Update name
205-
body = {"name": "Updated Name"}
206-
response = tenant_controller.update_tenant("update-tenant-1", body)
207-
assert response.status_code == 200
208-
209-
# Verify update
210-
updated_tenant = M8flowTenantModel.query.filter_by(id="update-tenant-1").first()
211-
assert updated_tenant.name == "Updated Name"
212-
213-
def test_update_tenant_status_success(self, app, mock_admin_user):
214-
"""Test successfully updating tenant status."""
215-
with app.app_context():
216-
with app.test_request_context("/"):
217-
g.user = mock_admin_user
218-
219-
# Create tenant
220-
tenant = M8flowTenantModel(
221-
id="status-tenant-1",
222-
name="Status Tenant",
223-
slug="status-tenant",
224-
status=TenantStatus.ACTIVE,
225-
created_by="admin",
226-
modified_by="admin"
227-
)
228-
db.session.add(tenant)
229-
db.session.commit()
230-
231-
# Update status
232-
body = {"status": "INACTIVE"}
233-
response = tenant_controller.update_tenant("status-tenant-1", body)
234-
assert response.status_code == 200
235-
236-
# Verify update
237-
updated_tenant = M8flowTenantModel.query.filter_by(id="status-tenant-1").first()
238-
assert updated_tenant.status == TenantStatus.INACTIVE
239-
240-
def test_update_tenant_slug_forbidden(self, app, mock_admin_user):
241-
"""Test that updating tenant slug is forbidden."""
242-
with app.app_context():
243-
with app.test_request_context("/"):
244-
g.user = mock_admin_user
245-
246-
# Create tenant
247-
tenant = M8flowTenantModel(
248-
id="immutable-slug-1",
249-
name="Immutable Slug",
250-
slug="original-slug",
251-
status=TenantStatus.ACTIVE,
252-
created_by="admin",
253-
modified_by="admin"
254-
)
255-
db.session.add(tenant)
256-
db.session.commit()
257-
258-
# Attempt to update slug
259-
body = {"slug": "new-slug"}
260-
261-
response = tenant_controller.update_tenant("immutable-slug-1", body)
262-
assert response.status_code == 400
263-
assert response.get_json()["error_code"] == "slug_update_forbidden"
264-
265-
def test_update_deleted_tenant_forbidden(self, app, mock_admin_user):
266-
"""Test that updating a deleted tenant is forbidden."""
267-
with app.app_context():
268-
with app.test_request_context("/"):
269-
g.user = mock_admin_user
270-
271-
# Create deleted tenant
272-
tenant = M8flowTenantModel(
273-
id="deleted-tenant-1",
274-
name="Deleted Tenant",
275-
slug="deleted-tenant",
276-
status=TenantStatus.DELETED,
277-
created_by="admin",
278-
modified_by="admin"
279-
)
280-
db.session.add(tenant)
281-
db.session.commit()
282-
283-
# Attempt to update
284-
body = {"name": "New Name"}
285-
286-
response = tenant_controller.update_tenant("deleted-tenant-1", body)
287-
assert response.status_code == 400
288-
assert response.get_json()["error_code"] == "tenant_deleted"
289-
290-
291-
def test_permission_check_update_no_user(self, app):
292-
"""Test that update fails when user is not authenticated."""
293-
with app.app_context():
294-
with app.test_request_context("/"):
295-
# No user in g
296-
297-
body = {"name": "New Name"}
298-
response = tenant_controller.update_tenant("some-tenant", body)
299-
assert response.status_code == 401
300-
assert response.get_json()["error_code"] == "not_authenticated"

0 commit comments

Comments
 (0)