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
93 changes: 7 additions & 86 deletions extensions/m8flow-backend/src/m8flow_backend/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,44 +19,6 @@ paths:
schema:
$ref: "#/components/schemas/HealthResponse"

/create-tenant:
post:
summary: Create a new tenant
operationId: m8flow_backend.routes.tenant_controller.create_tenant
tags:
- Tenant
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateTenantRequest"
responses:
"201":
description: Tenant created successfully
content:
application/json:
schema:
$ref: "#/components/schemas/TenantResponse"
"400":
description: Bad request - missing required fields
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Unauthorized - user not authenticated
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Conflict - tenant with ID or slug already exists
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"

/tenant-login-url:
get:
summary: Get Keycloak login URL for a tenant realm (unauthenticated)
Expand Down Expand Up @@ -329,9 +291,9 @@ paths:
description: Tenant not found

put:
summary: Update a tenant
description: Updates tenant name and/or status. Slug cannot be updated. Cannot update DELETED tenants.
operationId: m8flow_backend.routes.tenant_controller.update_tenant
summary: Update tenant display name
description: Updates the tenant's human-readable name in both the local database and the Keycloak realm (displayName). The slug and ID are immutable and cannot be changed.
operationId: m8flow_backend.routes.keycloak_controller.update_tenant_name
tags:
- Tenant
parameters:
Expand All @@ -340,24 +302,21 @@ paths:
required: true
schema:
type: string
description: Unique identifier of the tenant to update
description: Unique identifier (UUID) of the tenant to update
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
description: New name for the tenant
status:
type: string
enum: [ACTIVE, INACTIVE, DELETED]
description: New status for the tenant
description: New display name for the tenant
example:
name: "Updated Tenant Name"
status: "ACTIVE"
responses:
"200":
description: Tenant updated successfully
Expand All @@ -384,44 +343,6 @@ paths:
"500":
description: Internal server error

delete:
summary: Soft delete a tenant
description: Sets the tenant status to DELETED without removing from database
operationId: m8flow_backend.routes.tenant_controller.delete_tenant
tags:
- Tenant
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
description: Unique identifier of the tenant to delete
responses:
"200":
description: Tenant soft deleted successfully
content:
application/json:
schema:
$ref: "#/components/schemas/MessageResponse"
"400":
description: Bad request - tenant already deleted
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Unauthorized - user not authenticated
"403":
description: Forbidden - cannot delete default tenant
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Tenant not found
"500":
description: Internal server error

/templates:
get:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ permissions:
groups: ["super-admin"]
actions: [all]
uri: /m8flow/tenants/*
manage-tenant-realms:
groups: ["super-admin"]
actions: [all]
uri: /m8flow/tenant-realms*
read-all-process-groups:
groups: ["viewer"]
actions: [read]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,41 @@
realm_exists,
tenant_login as tenant_login_svc,
tenant_login_authorization_url,
update_realm,
verify_admin_token,
get_master_admin_token,
)
from sqlalchemy.exc import IntegrityError
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from m8flow_backend.helpers.response_helper import success_response, handle_api_errors

from m8flow_backend.tenancy import create_tenant_if_not_exists
from m8flow_backend.models.m8flow_tenant import M8flowTenantModel
from spiffworkflow_backend.models.db import db
from flask import request
from flask import request, g

logger = logging.getLogger(__name__)


def create_realm(body: dict) -> tuple[dict, int]:
"""Create a spoke realm from the spiffworkflow template. Returns (response_dict, status_code)."""

user = getattr(g, 'user', None)
if not user:
raise ApiError(error_code="not_authenticated", message="User not authenticated", status_code=401)

Check failure on line 37 in extensions/m8flow-backend/src/m8flow_backend/routes/keycloak_controller.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "User not authenticated" 3 times.

See more on https://sonarcloud.io/project/issues?id=AOT-Technologies_m8flow&issues=AZ0A_L_2sQnINl7zBmT7&open=AZ0A_L_2sQnINl7zBmT7&pullRequest=77

is_authorized = AuthorizationService.user_has_permission(user, "create", request.path)

if not is_authorized:
logger.warning(
"User %s (groups: %s) attempted to create a tenant/realm without required permissions",
user.username,
[getattr(g, 'identifier', g.name) for g in getattr(user, 'groups', [])],
)
raise ApiError(error_code="forbidden", message="Not authorized to create a tenant.", status_code=403)


realm_id = body.get("realm_id")
if not realm_id or not str(realm_id).strip():
return {"detail": "realm_id is required"}, 400
Expand Down Expand Up @@ -142,15 +163,23 @@
with ON DELETE RESTRICT. If any rows still reference this tenant, the delete returns
409 and the caller must remove or reassign those references first (or use soft delete).
"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return {"detail": "Authorization header with Bearer token is required"}, 401

admin_token = auth_header.split(" ")[1]
if not verify_admin_token(admin_token):
return {"detail": "Invalid or unauthorized admin token"}, 401
user = getattr(g, 'user', None)
if not user:
raise ApiError(error_code="not_authenticated", message="User not authenticated", status_code=401)

is_authorized = AuthorizationService.user_has_permission(user, "delete", request.path)

if not is_authorized:
logger.warning(
"User %s (groups: %s) attempted to delete tenant %s without required permissions",
user.username,
[getattr(g, 'identifier', g.name) for g in getattr(user, 'groups', [])],
realm_id
)
raise ApiError(error_code="forbidden", message="Not authorized to delete a tenant.", status_code=403)

try:
admin_token = get_master_admin_token()
# Delete from Keycloak first. If this raises, we do not touch Postgres.
delete_realm(realm_id, admin_token=admin_token)

Expand Down Expand Up @@ -201,3 +230,56 @@
except Exception as e:
logger.exception("Error deleting tenant %s", realm_id)
return {"detail": str(e)}, 500


@handle_api_errors
def update_tenant_name(tenant_id: str, body: dict) -> tuple[dict, int]:
"""Update a tenant's display name. Requires appropriate permissions."""
user = getattr(g, 'user', None)
if not user:
raise ApiError(error_code="not_authenticated", message="User not authenticated", status_code=401)

is_authorized = AuthorizationService.user_has_permission(user, "update", request.path)

if not is_authorized:
logger.warning(
"User %s (groups: %s) attempted to update tenant %s without required permissions",
user.username,
[getattr(g, 'identifier', g.name) for g in getattr(user, 'groups', [])],
tenant_id
)
raise ApiError(error_code="forbidden", message="Not authorized to update the tenant name.", status_code=403)

new_name = body.get("name")
if not new_name or not str(new_name).strip():
return {"detail": "name is required"}, 400
new_name = str(new_name).strip()

try:
tenant = (
db.session.query(M8flowTenantModel)
.filter(M8flowTenantModel.id == tenant_id)
.one_or_none()
)
if not tenant:
return {"detail": "Tenant not found"}, 404

admin_token = get_master_admin_token()

update_realm(tenant.slug, display_name=new_name, admin_token=admin_token)
tenant.name = new_name
db.session.commit()
logger.info("Updated tenant name: id=%s slug=%s to name=%s (updated by %s)",
tenant_id, tenant.slug, new_name, user.username)

return {"message": "Tenant name updated successfully", "name": new_name}, 200

except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else 500
detail = (e.response.text or str(e))[:500] if e.response is not None else str(e)
logger.warning("Keycloak update realm HTTP error: %s %s", status, detail)
return {"detail": detail}, status
except Exception as e:
db.session.rollback()
logger.exception("Error updating tenant name %s", tenant_id)
return {"detail": str(e)}, 500
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from spiffworkflow_backend.exceptions.api_error import ApiError
from m8flow_backend.services.tenant_service import TenantService
from m8flow_backend.helpers.response_helper import success_response, handle_api_errors
import uuid


def _serialize_tenant(tenant):
Expand Down Expand Up @@ -40,27 +39,6 @@ def _require_authenticated_user():
return user


@handle_api_errors
def create_tenant(body):
user = _require_authenticated_user()
body = body or {}

tenant_id = body.get('id', str(uuid.uuid4()))
name = body.get('name')
slug = body.get('slug')
status_str = "ACTIVE"

tenant = TenantService.create_tenant(
tenant_id=tenant_id,
name=name,
slug=slug,
status_str=status_str,
user_id=user.username
)

return success_response(_serialize_tenant(tenant), 201)


@handle_api_errors
def get_tenant_by_id(tenant_id):
"""Fetch tenant by ID."""
Expand All @@ -81,36 +59,3 @@ def get_all_tenants():
tenants = TenantService.get_all_tenants()
return success_response([_serialize_tenant(t) for t in tenants], 200)

@handle_api_errors
def delete_tenant(tenant_id):
"""Soft delete a tenant by setting status to DELETED."""
user = _require_authenticated_user()
tenant = TenantService.delete_tenant(tenant_id, user.username)

return success_response({
"message": f"Tenant '{tenant.name}' has been successfully deleted."
}, 200)

@handle_api_errors
def update_tenant(tenant_id, body):
"""Update tenant name and status. Slug cannot be updated."""
user = _require_authenticated_user()
body = body or {}

if 'slug' in body:
raise ApiError(
error_code="slug_update_forbidden",
message="Slug cannot be updated. It is immutable after creation.",
status_code=400
)

tenant = TenantService.update_tenant(
tenant_id=tenant_id,
name=body.get('name'),
status_str=body.get('status'),
user_id=user.username
)

return success_response({
"message": f"Tenant '{tenant.name}' has been successfully updated."
}, 200)
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
# and bootstrap: create realm / create tenant — no tenant in token yet; Keycloak admin is server-side).
M8FLOW_AUTH_EXCLUSION_ADDITIONS = [
"m8flow_backend.routes.keycloak_controller.get_tenant_login_url",
"m8flow_backend.routes.keycloak_controller.create_realm",
"m8flow_backend.routes.tenant_controller.create_tenant",
"m8flow_backend.tenancy.health_check",
]
M8FLOW_ROLE_GROUP_IDENTIFIERS = frozenset(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,7 @@
2. Use Keycloak partial import to add clients, roles, groups, and users from template
"""
if not realm_id or not realm_id.strip():
raise ValueError("realm_id is required")

Check failure on line 845 in extensions/m8flow-backend/src/m8flow_backend/services/keycloak_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "realm_id is required" 3 times.

See more on https://sonarcloud.io/project/issues?id=AOT-Technologies_m8flow&issues=AZznbsDQjYkGbwE8YrW7&open=AZznbsDQjYkGbwE8YrW7&pullRequest=77
realm_id = realm_id.strip()
logger.debug(
"create_realm_from_template: realm_id=%r keycloak_url=%s",
Expand Down Expand Up @@ -1045,6 +1045,38 @@
logger.info("Deleted Keycloak realm: %s", realm_id)


def update_realm(realm_id: str, display_name: str, admin_token: str | None = None) -> None:
"""Update a realm in Keycloak (specifically displayName)."""
if not realm_id or not str(realm_id).strip():
raise ValueError("realm_id is required")

if not display_name or not str(display_name).strip():
raise ValueError("display_name is required")

if not admin_token or not str(admin_token).strip():
raise ValueError("admin_token is required")

realm_id = str(realm_id).strip()
display_name = str(display_name).strip()
admin_token = str(admin_token).strip()

base_url = keycloak_url()

payload = {
"realm": realm_id,
"displayName": display_name
}

r = requests.put(
f"{base_url}/admin/realms/{realm_id}",
json=payload,
headers={"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
logger.info("Updated Keycloak realm %s: displayName=%s", realm_id, display_name)


def verify_admin_token(token: str) -> bool:
"""
Verify that the provided token is a valid admin token.
Expand Down
Loading
Loading